discourse/plugins/discourse-assign/plugin.rb
Régis HANOL 026eae7352
FIX: show group Assignments tab based on assignable_level (#39085)
The Assignments tab on group pages had two problematic visibility
conditions that caused it to disappear unexpectedly:

1. `can_show_assigned_tab?` checked whether ALL members of the group
belonged to an `assign_allowed_on_groups` group. This meant a single
member outside those groups would hide the tab for everyone — even when
the group was fully assignable and had active assignments.

2. The frontend required `assignment_count > 0`, so the tab silently
vanished when the last assignment was resolved, with no indication of
whether the feature was unconfigured or simply empty.

The root cause is that tab visibility was answering the wrong question:
"can all members use the assign feature?" instead of "is this group set
up to receive assignments?"

This replaces the member-overlap SQL in `can_show_assigned_tab?` with a
simple check on `assignable_level > nobody` — the setting that actually
controls whether a group can receive assignments. The `assignment_count
> 0` gate is removed from the frontend so the tab stays visible with an
empty state, consistent with other group tabs.

Also fixes the `assign_allowed_on_groups` setting description which
incorrectly stated it controls who can be assigned topics (that's
`assignable_level`).

Ref - t/179679
2026-04-08 16:50:27 +02:00

1036 lines
34 KiB
Ruby

# frozen_string_literal: true
# name: discourse-assign
# about: Provides the ability to assign topics and individual posts to a user or group.
# meta_topic_id: 58044
# version: 1.0.1
# authors: Sam Saffron
# url: https://github.com/discourse/discourse/tree/main/plugins/discourse-assign
enabled_site_setting :assign_enabled
register_asset "stylesheets/assigns.scss"
register_asset "stylesheets/mobile/assigns.scss", :mobile
%w[user-plus user-xmark group-plus group-times].each { |i| register_svg_icon(i) }
module ::DiscourseAssign
PLUGIN_NAME = "discourse-assign"
end
require_relative "lib/discourse_assign/engine"
require_relative "lib/validators/assign_statuses_validator"
after_initialize do
UserUpdater::OPTION_ATTR.push(:notification_level_when_assigned)
reloadable_patch do |plugin|
Group.prepend(DiscourseAssign::GroupExtension)
ListController.prepend(DiscourseAssign::ListControllerExtension)
Post.prepend(DiscourseAssign::PostExtension)
Topic.prepend(DiscourseAssign::TopicExtension)
WebHook.prepend(DiscourseAssign::WebHookExtension)
Notification.prepend(DiscourseAssign::NotificationExtension)
UserOption.prepend(DiscourseAssign::UserOptionExtension)
end
add_to_serializer(:user_option, :notification_level_when_assigned) do
object.notification_level_when_assigned
end
add_to_serializer(:current_user_option, :notification_level_when_assigned) do
object.notification_level_when_assigned
end
register_group_param(:assignable_level)
register_groups_callback_for_users_search_controller_action(:assignable_groups) do |groups, user|
groups.assignable(user)
end
frequency_field = PendingAssignsReminder::REMINDERS_FREQUENCY
register_editable_user_custom_field frequency_field
register_user_custom_field_type(frequency_field, :integer, max_length: 10)
DiscoursePluginRegistry.serialized_current_user_fields << frequency_field
add_to_serializer(:user, :reminders_frequency) { RemindAssignsFrequencySiteSettings.values }
add_to_serializer(:group_show, :assignment_count, include_condition: -> { scope.can_assign? }) do
Topic.joins(<<~SQL).where(<<~SQL, group_id: object.id).where("topics.deleted_at IS NULL").count
JOIN assignments a
ON topics.id = a.topic_id AND a.assigned_to_id IS NOT NULL
SQL
a.active AND
((
a.assigned_to_type = 'User' AND a.assigned_to_id IN (
SELECT group_users.user_id
FROM group_users
WHERE group_id = :group_id
)
) OR (
a.assigned_to_type = 'Group' AND a.assigned_to_id = :group_id
))
SQL
end
add_to_serializer(:group_show, :assignable_level) { object.assignable_level }
add_to_serializer(:group_show, :can_show_assigned_tab?) { object.can_show_assigned_tab? }
add_model_callback(UserCustomField, :before_save) do
self.value = self.value.to_i if self.name == frequency_field
end
add_class_method(:group, :assign_allowed_groups) do
allowed_groups = SiteSetting.assign_allowed_on_groups.split("|")
where(id: allowed_groups)
end
add_to_class(:user, :can_assign?) do
return @can_assign if defined?(@can_assign)
allowed_groups = SiteSetting.assign_allowed_on_groups_map
@can_assign = admin? || (allowed_groups.present? && groups.where(id: allowed_groups).exists?)
end
add_to_serializer(:current_user, :never_auto_track_topics) do
(
user.user_option.auto_track_topics_after_msecs ||
SiteSetting.default_other_auto_track_topics_after_msecs
) < 0
end
add_to_class(:group, :can_show_assigned_tab?) do
self.assignable_level > Group::ALIAS_LEVELS[:nobody]
end
add_to_class(:guardian, :can_assign?) { user && user.can_assign? }
add_class_method(:user, :assign_allowed) do
allowed_groups = SiteSetting.assign_allowed_on_groups.split("|")
# The UNION against admin users is necessary because bot users like the system user are given the admin status but
# are not added into the admin group.
where(
"users.id IN (
SELECT
user_id
FROM group_users
WHERE group_users.group_id IN (?)
UNION
SELECT id
FROM users
WHERE users.admin
)",
allowed_groups,
)
end
add_model_callback(Group, :before_update) do
if name_changed?
SiteSetting.assign_allowed_on_groups =
SiteSetting.assign_allowed_on_groups.gsub(name_was, name)
end
end
add_model_callback(Group, :before_destroy) do
new_setting = SiteSetting.assign_allowed_on_groups.gsub(/#{id}[|]?/, "")
new_setting = new_setting.chomp("|") if new_setting.ends_with?("|")
SiteSetting.assign_allowed_on_groups = new_setting
end
on(:assign_topic) do |topic, user, assigning_user, force|
Assigner.new(topic, assigning_user).assign(user) if force || !Assignment.exists?(target: topic)
end
on(:unassign_topic) { |topic, unassigning_user| Assigner.new(topic, unassigning_user).unassign }
register_preloaded_category_custom_fields("enable_unassigned_filter")
BookmarkQuery.on_preload do |bookmarks, _bookmark_query|
if SiteSetting.assign_enabled?
topics =
Bookmark
.select_type(bookmarks, "Topic")
.map(&:bookmarkable)
.concat(Bookmark.select_type(bookmarks, "Post").map { |bm| bm.bookmarkable.topic })
.uniq
assignments =
Assignment
.strict_loading
.where(topic_id: topics)
.includes(:assigned_to)
.index_by(&:topic_id)
topics.each do |topic|
assignment = assignments[topic.id]
# NOTE: preloading to `nil` is necessary to avoid N+1 queries
topic.preload_assigned_to(assignment&.assigned_to)
topic.preload_assignment_status(assignment&.status)
end
end
end
TopicView.on_preload do |topic_view|
if SiteSetting.assign_enabled
topic_view.instance_variable_set(:@posts, topic_view.posts.includes(:assignment))
end
end
TopicList.on_preload do |topics, topic_list|
next unless SiteSetting.assign_enabled?
can_assign = topic_list.current_user&.can_assign?
allowed_access = SiteSetting.assigns_public || can_assign
next if !allowed_access || topics.empty?
assignments =
Assignment.strict_loading.active.where(topic: topics).includes(:target, :assigned_to)
assignments_map = assignments.group_by(&:topic_id)
user_ids = assignments.filter(&:assigned_to_user?).map(&:assigned_to_id)
users_map = User.where(id: user_ids).select(UserLookup.lookup_columns).index_by(&:id)
group_ids = assignments.filter(&:assigned_to_group?).map(&:assigned_to_id)
groups_map = Group.where(id: group_ids).index_by(&:id)
topics.each do |topic|
if assignments = assignments_map[topic.id]
topic_assignments, post_assignments = assignments.partition { it.target_type == "Topic" }
direct_assignment = topic_assignments.find { it.target_id == topic.id }
indirectly_assigned_to = {}
post_assignments.each do |assignment|
next unless assignment.target
if assignment.assigned_to_user?
indirectly_assigned_to[assignment.target_id] = {
assigned_to: users_map[assignment.assigned_to_id],
post_number: assignment.target.post_number,
}
elsif assignment.assigned_to_group?
indirectly_assigned_to[assignment.target_id] = {
assigned_to: groups_map[assignment.assigned_to_id],
post_number: assignment.target.post_number,
}
end
end
assigned_to =
if direct_assignment&.assigned_to_user?
users_map[direct_assignment.assigned_to_id]
elsif direct_assignment&.assigned_to_group?
groups_map[direct_assignment.assigned_to_id]
end
end
# NOTE: preloading to `nil` is necessary to avoid N+1 queries
topic.preload_assigned_to(assigned_to)
topic.preload_assignment_status(direct_assignment&.status)
topic.preload_indirectly_assigned_to(indirectly_assigned_to)
end
end
Search.on_preload do |results, search|
next unless SiteSetting.assign_enabled?
can_assign = search.guardian&.can_assign?
allowed_access = SiteSetting.assigns_public || can_assign
next if !allowed_access || results.posts.empty?
topics = results.posts.map(&:topic)
assignments =
Assignment
.strict_loading
.active
.where(topic: topics)
.includes(:assigned_to, :target)
.group_by(&:topic_id)
results.posts.each do |post|
if topic_assignments = assignments[post.topic_id]
direct_assignment = topic_assignments.find { it.target_type == "Topic" }
indirect_assignments = topic_assignments.select { it.target_type == "Post" }
end
if indirect_assignments.present?
indirect_assignment_map = {}
indirect_assignments.each do |assignment|
next unless assignment.target
indirect_assignment_map[assignment.target_id] = {
assigned_to: assignment.assigned_to,
post_number: assignment.target.post_number,
}
end
end
# NOTE: preloading to `nil` is necessary to avoid N+1 queries
post.topic.preload_assigned_to(direct_assignment&.assigned_to)
post.topic.preload_assignment_status(direct_assignment&.status)
post.topic.preload_indirectly_assigned_to(indirect_assignment_map)
end
end
# TopicQuery
TopicQuery.add_custom_filter(:assigned) do |results, topic_query|
name = topic_query.options[:assigned]
next results if name.blank?
next results if !topic_query.guardian.can_assign? && !SiteSetting.assigns_public
if name == "nobody"
next(
results.joins("LEFT JOIN assignments a ON a.topic_id = topics.id AND active").where(
"a.assigned_to_id IS NULL",
)
)
end
if name == "*"
next(
results
.joins("JOIN assignments a ON a.topic_id = topics.id AND active")
.where.not(a: { assigned_to_id: nil })
)
end
user_id = topic_query.guardian.user.id if name == "me"
user_id ||= User.where(username_lower: name.downcase).pick(:id)
if user_id
next(
results.joins("JOIN assignments a ON a.topic_id = topics.id AND active").where(
"a.assigned_to_id = ? AND a.assigned_to_type = 'User'",
user_id,
)
)
end
group_id =
Group
.visible_groups(topic_query.guardian.user)
.members_visible_groups(topic_query.guardian.user)
.where(name: name.downcase)
.pick(:id)
if group_id
next(
results.joins("JOIN assignments a ON a.topic_id = topics.id AND active").where(
"a.assigned_to_id = ? AND a.assigned_to_type = 'Group'",
group_id,
)
)
end
next results
end
add_to_class(:topic_query, :list_messages_assigned) do |user, ignored_assignment_ids = nil|
list = default_results(include_pms: true)
where_clause = +"("
where_clause << "(assigned_to_id = :user_id AND assigned_to_type = 'User' AND active)"
if @options[:filter] != :direct
where_clause << "OR (assigned_to_id IN (group_users.group_id) AND assigned_to_type = 'Group' AND active)"
end
where_clause << ")"
if ignored_assignment_ids.present?
where_clause << "AND assignments.id NOT IN (:ignored_assignment_ids)"
end
topic_ids_sql = +<<~SQL
SELECT topic_id FROM assignments
LEFT JOIN group_users ON group_users.user_id = :user_id
WHERE #{where_clause}
SQL
where_args = { user_id: user.id }
where_args[:ignored_assignment_ids] = ignored_assignment_ids if ignored_assignment_ids.present?
list = list.where("topics.id IN (#{topic_ids_sql})", **where_args).includes(:allowed_users)
create_list(:assigned, { unordered: true }, list)
end
add_to_class(:topic_query, :group_topics_assigned_results) do |group|
list = default_results(include_pms: true)
assignee_condition = "(a.assigned_to_id = :group_id AND a.assigned_to_type = 'Group')"
if @options[:filter] != :direct
assignee_condition +=
" OR (a.assigned_to_id IN (SELECT user_id from group_users where group_id = :group_id) AND a.assigned_to_type = 'User')"
end
topic_ids_sql = <<~SQL
SELECT a.topic_id FROM assignments a
LEFT JOIN topics t ON t.id = a.topic_id
LEFT JOIN posts p ON p.id = a.target_id AND a.target_type = 'Post'
WHERE a.active
AND t.deleted_at IS NULL
AND (
a.target_type = 'Topic' OR
(a.target_type = 'Post' AND p.deleted_at IS NULL AND p.deleted_by_id IS NULL AND p.user_deleted = false)
)
AND (#{assignee_condition})
SQL
sql = "topics.id IN (#{topic_ids_sql})"
list = list.where(sql, group_id: group.id).includes(:allowed_users)
end
add_to_class(:topic_query, :list_group_topics_assigned) do |group|
create_list(:assigned, { unordered: true }, group_topics_assigned_results(group))
end
add_to_class(:topic_query, :list_private_messages_assigned) do |user|
list = private_messages_assigned_query(user)
create_list(:private_messages, {}, list)
end
add_to_class(:topic_query, :private_messages_assigned_query) do |user|
list = private_messages_for(user, :all)
group_ids = user.groups.map(&:id)
list = list.where(<<~SQL, user_id: user.id, group_ids: group_ids)
topics.id IN (
SELECT topic_id FROM assignments WHERE
active AND
((assigned_to_id = :user_id AND assigned_to_type = 'User') OR
(assigned_to_id IN (:group_ids) AND assigned_to_type = 'Group'))
)
SQL
end
# ListController
add_to_class(:list_controller, :messages_assigned) do
user = User.find_by_username(params[:username])
raise Discourse::NotFound unless user
raise Discourse::InvalidAccess unless current_user.can_assign?
list_opts = build_topic_list_options
list_opts.merge!({ filter: :direct }) if params[:direct] == "true"
list = generate_list_for("messages_assigned", user, list_opts)
list.more_topics_url = construct_url_with(:next, list_opts)
list.prev_topics_url = construct_url_with(:prev, list_opts)
respond_with_list(list)
end
add_to_class(:list_controller, :group_topics_assigned) do
group = Group.find_by("name = ?", params[:groupname])
guardian.ensure_can_see_group_members!(group)
raise Discourse::NotFound unless group
raise Discourse::InvalidAccess unless current_user.can_assign?
raise Discourse::InvalidAccess unless group.can_show_assigned_tab?
list_opts = build_topic_list_options
list_opts.merge!({ filter: :direct }) if params[:direct] == "true"
list = generate_list_for("group_topics_assigned", group, list_opts)
list.more_topics_url = construct_url_with(:next, list_opts)
list.prev_topics_url = construct_url_with(:prev, list_opts)
respond_with_list(list)
end
# Topic
add_to_class(:topic, :assigned_to) do
return @assigned_to if defined?(@assigned_to)
@assigned_to = assignment.assigned_to if assignment&.active
end
add_to_class(:topic, :assignment_status) do
return @assignment_status if defined?(@assignment_status)
@assignment_status = assignment.status if SiteSetting.enable_assign_status && assignment&.active
end
add_to_class(:topic, :indirectly_assigned_to) do
return @indirectly_assigned_to if defined?(@indirectly_assigned_to)
@indirectly_assigned_to =
Assignment
.where(topic_id: id, target_type: "Post", active: true)
.includes(:target)
.inject({}) do |acc, assignment|
if assignment.target
acc[assignment.target_id] = {
assigned_to: assignment.assigned_to,
post_number: assignment.target.post_number,
assignment_note: assignment.note,
}
acc[assignment.target_id][
:assignment_status
] = assignment.status if SiteSetting.enable_assign_status
end
acc
end
end
add_to_class(:topic, :preload_assigned_to) { |assigned_to| @assigned_to = assigned_to }
add_to_class(:topic, :preload_assignment_status) do |assignment_status|
@assignment_status = assignment_status
end
add_to_class(:topic, :preload_indirectly_assigned_to) do |indirectly_assigned_to|
@indirectly_assigned_to = indirectly_assigned_to
end
# TopicList serializer
add_to_serializer(
:topic_list,
:assigned_messages_count,
include_condition: -> do
options = object.instance_variable_get(:@opts)
if assigned_user = options.dig(:assigned)
scope.can_assign? || assigned_user.downcase == scope.current_user&.username_lower
end
end,
) do
TopicQuery
.new(object.current_user, guardian: scope, limit: false)
.private_messages_assigned_query(object.current_user)
.count
end
# TopicView serializer
add_to_serializer(
:topic_view,
:assigned_to_user,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.topic.assigned_to.is_a?(User)
end,
) { DiscourseAssign::Helpers.build_assigned_to_user(object.topic.assigned_to, object.topic) }
add_to_serializer(
:topic_view,
:assigned_to_group,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.topic.assigned_to.is_a?(Group)
end,
) { DiscourseAssign::Helpers.build_assigned_to_group(object.topic.assigned_to, object.topic) }
add_to_serializer(
:topic_view,
:indirectly_assigned_to,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) &&
object.topic.indirectly_assigned_to.present?
end,
) do
DiscourseAssign::Helpers.build_indirectly_assigned_to(
object.topic.indirectly_assigned_to,
object.topic,
)
end
add_to_serializer(
:topic_view,
:assignment_note,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.topic.assignment.present?
end,
) { object.topic.assignment.note }
add_to_serializer(
:topic_view,
:assignment_status,
include_condition: -> do
SiteSetting.enable_assign_status && (SiteSetting.assigns_public || scope.can_assign?) &&
object.topic.assignment_status.present?
end,
) { object.topic.assignment_status }
# SuggestedTopic serializer
add_to_serializer(
:suggested_topic,
:assigned_to_user,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.assigned_to.is_a?(User)
end,
) { DiscourseAssign::Helpers.build_assigned_to_user(object.assigned_to, object) }
add_to_serializer(
:suggested_topic,
:assigned_to_group,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.assigned_to.is_a?(Group)
end,
) { DiscourseAssign::Helpers.build_assigned_to_group(object.assigned_to, object) }
add_to_serializer(
:suggested_topic,
:indirectly_assigned_to,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.indirectly_assigned_to.present?
end,
) { DiscourseAssign::Helpers.build_indirectly_assigned_to(object.indirectly_assigned_to, object) }
# TopicListItem serializer
add_to_serializer(
:topic_list_item,
:indirectly_assigned_to,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.indirectly_assigned_to.present?
end,
) { DiscourseAssign::Helpers.build_indirectly_assigned_to(object.indirectly_assigned_to, object) }
add_to_serializer(
:topic_list_item,
:assigned_to_user,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.assigned_to.is_a?(User)
end,
) { BasicUserSerializer.new(object.assigned_to, scope: scope, root: false).as_json }
add_to_serializer(
:topic_list_item,
:assigned_to_group,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.assigned_to.is_a?(Group)
end,
) { AssignedGroupSerializer.new(object.assigned_to, scope: scope, root: false).as_json }
add_to_serializer(
:topic_list_item,
:assignment_status,
include_condition: -> do
SiteSetting.enable_assign_status && (SiteSetting.assigns_public || scope.can_assign?) &&
object.assignment_status.present?
end,
) { object.assignment_status }
# SearchTopicListItem serializer
add_to_serializer(
:search_topic_list_item,
:assigned_to_user,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.assigned_to.is_a?(User)
end,
) { DiscourseAssign::Helpers.build_assigned_to_user(object.assigned_to, object) }
add_to_serializer(
:search_topic_list_item,
:assigned_to_group,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.assigned_to.is_a?(Group)
end,
) { AssignedGroupSerializer.new(object.assigned_to, scope: scope, root: false).as_json }
add_to_serializer(
:search_topic_list_item,
:indirectly_assigned_to,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.indirectly_assigned_to.present?
end,
) { DiscourseAssign::Helpers.build_indirectly_assigned_to(object.indirectly_assigned_to, object) }
# TopicsBulkAction
TopicsBulkAction.register_operation("assign") do
if @user.can_assign?
assign_user = User.find_by_username(@operation[:username])
topics.each do |topic|
Assigner.new(topic, @user).assign(
assign_user,
status: @operation[:status],
note: @operation[:note],
)
end
end
end
TopicsBulkAction.register_operation("unassign") do
if @user.can_assign?
topics.each { |topic| Assigner.new(topic, @user).unassign if guardian.can_assign? }
end
end
register_permitted_bulk_action_parameter :username
register_permitted_bulk_action_parameter :status
register_permitted_bulk_action_parameter :note
add_to_class(:user_bookmark_base_serializer, :assigned_to) do
@assigned_to ||=
bookmarkable_type == "Topic" ? bookmarkable.assigned_to : bookmarkable.topic.assigned_to
end
add_to_class(:user_bookmark_base_serializer, :can_have_assignment?) do
%w[Post Topic].include?(bookmarkable_type)
end
add_to_serializer(
:user_bookmark_base,
:assigned_to_user,
include_condition: -> do
return false if !can_have_assignment?
(SiteSetting.assigns_public || scope.can_assign?) && assigned_to.is_a?(User)
end,
) do
return if !can_have_assignment?
BasicUserSerializer.new(assigned_to, scope: scope, root: false).as_json
end
add_to_serializer(
:user_bookmark_base,
:assigned_to_group,
include_condition: -> do
return false if !can_have_assignment?
(SiteSetting.assigns_public || scope.can_assign?) && assigned_to.is_a?(Group)
end,
) do
return if !can_have_assignment?
AssignedGroupSerializer.new(assigned_to, scope: scope, root: false).as_json
end
# PostSerializer
add_to_serializer(
:post,
:assigned_to_user,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) &&
object.assignment&.assigned_to.is_a?(User) && object.assignment.active
end,
) { BasicUserSerializer.new(object.assignment.assigned_to, scope: scope, root: false).as_json }
add_to_serializer(
:post,
:assigned_to_group,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) &&
object.assignment&.assigned_to.is_a?(Group) && object.assignment.active
end,
) do
AssignedGroupSerializer.new(object.assignment.assigned_to, scope: scope, root: false).as_json
end
add_to_serializer(
:post,
:assignment_note,
include_condition: -> do
(SiteSetting.assigns_public || scope.can_assign?) && object.assignment.present?
end,
) { object.assignment.note }
add_to_serializer(
:post,
:assignment_status,
include_condition: -> do
SiteSetting.enable_assign_status && (SiteSetting.assigns_public || scope.can_assign?) &&
object.assignment.present?
end,
) { object.assignment.status }
# CurrentUser serializer
add_to_serializer(:current_user, :can_assign) { object.can_assign? }
# FlaggedTopic serializer
add_to_serializer(
:flagged_topic,
:assigned_to_user,
include_condition: -> { object.assigned_to && object.assigned_to.is_a?(User) },
) { DiscourseAssign::Helpers.build_assigned_to_user(object.assigned_to, object) }
add_to_serializer(
:flagged_topic,
:assigned_to_group,
include_condition: -> { object.assigned_to && object.assigned_to.is_a?(Group) },
) { DiscourseAssign::Helpers.build_assigned_to_group(object.assigned_to, object) }
# Reviewable
add_custom_reviewable_filter(
[
:assigned_to,
Proc.new do |results, value|
results.joins(<<~SQL).where(target_type: Post.name).where("u.username = ?", value)
INNER JOIN posts p ON p.id = target_id
INNER JOIN topics t ON t.id = p.topic_id
INNER JOIN assignments a ON a.topic_id = t.id AND a.assigned_to_type = 'User'
INNER JOIN users u ON u.id = a.assigned_to_id
SQL
end,
],
)
# TopicTrackingState
add_class_method(:topic_tracking_state, :publish_assigned_private_message) do |topic, assignee|
return unless topic.private_message?
opts = (assignee.is_a?(User) ? { user_ids: [assignee.id] } : { group_ids: [assignee.id] })
MessageBus.publish("/private-messages/assigned", { topic_id: topic.id }, opts)
end
# Event listeners
on(:post_created) { |post| ::Assigner.auto_assign(post, force: true) }
on(:post_edited) { |post, topic_changed| ::Assigner.auto_assign(post, force: true) }
on(:topic_status_updated) do |topic, status, enabled|
if SiteSetting.unassign_on_close && (status == "closed" || status == "autoclosed") && enabled &&
Assignment.active.exists?(topic: topic)
assigner = ::Assigner.new(topic, Discourse.system_user)
assigner.unassign(silent: true, deactivate: true)
topic
.posts
.joins(:assignment)
.find_each do |post|
assigner = ::Assigner.new(post, Discourse.system_user)
assigner.unassign(silent: true, deactivate: true)
end
MessageBus.publish("/topic/#{topic.id}", reload_topic: true, refresh_stream: true)
end
if SiteSetting.reassign_on_open && (status == "closed" || status == "autoclosed") && !enabled &&
Assignment.inactive.exists?(topic: topic)
Assignment.reactivate!(topic: topic)
MessageBus.publish("/topic/#{topic.id}", reload_topic: true, refresh_stream: true)
end
end
on(:post_destroyed) do |post|
if Assignment.active.exists?(target: post)
post.assignment.deactivate!
MessageBus.publish("/topic/#{post.topic_id}", reload_topic: true, refresh_stream: true)
end
# small actions have to be destroyed as link is incorrect
PostCustomField
.where(name: "action_code_post_id", value: post.id)
.find_each do |post_custom_field|
next if post_custom_field.post == nil
if ![Post.types[:small_action], Post.types[:whisper]].include?(
post_custom_field.post.post_type,
)
next
end
post_custom_field.post.destroy
end
end
on(:post_recovered) do |post|
if SiteSetting.reassign_on_open && Assignment.inactive.exists?(target: post)
post.assignment.reactivate!
MessageBus.publish("/topic/#{post.topic_id}", reload_topic: true, refresh_stream: true)
end
end
on(:move_to_inbox) do |info|
topic = info[:topic]
if topic.assignment
TopicTrackingState.publish_assigned_private_message(topic, topic.assignment.assigned_to)
end
next if !SiteSetting.unassign_on_group_archive
next if !info[:group]
Assignment.reactivate!(topic: topic)
end
on(:archive_message) do |info|
topic = info[:topic]
next if !topic.assignment
TopicTrackingState.publish_assigned_private_message(topic, topic.assignment.assigned_to)
next if !SiteSetting.unassign_on_group_archive
next if !info[:group]
Assignment.deactivate!(topic: topic)
end
on(:user_added_to_group) do |user, group, automatic:|
group.assignments.active.find_each do |assignment|
Jobs.enqueue(:assign_notification, assignment_id: assignment.id)
end
end
on(:user_removed_from_group) do |user, group|
user.notifications.for_assignment(group.assignments.select(:id)).destroy_all
end
on(:post_moved) do |post, original_topic_id|
assignment =
Assignment.where(topic_id: original_topic_id, target_type: "Post", target_id: post.id).first
next if !assignment
if post.is_first_post?
assignment.update!(topic_id: post.topic_id, target_type: "Topic", target_id: post.topic_id)
else
assignment.update!(topic_id: post.topic_id)
end
end
on(:group_destroyed) do |group, user_ids|
User
.where(id: user_ids)
.find_each do |user|
user.notifications.for_assignment(group.assignments.select(:id)).destroy_all
end
Assignment.active_for_group(group).destroy_all
end
add_filter_custom_filter("assigned") do |scope, filter_values, guardian|
next if !guardian.can_assign? || filter_values.blank?
# Handle multiple comma-separated values (user1,group1,user2)
names =
filter_values.compact.flat_map { |value| value.to_s.split(",") }.map(&:strip).reject(&:blank?)
next if names.blank?
if names.include?("nobody")
next scope.where("topics.id NOT IN (SELECT a.topic_id FROM assignments a WHERE a.active)")
end
if names.include?("*")
next scope.where("topics.id IN (SELECT a.topic_id FROM assignments a WHERE a.active)")
end
found_names, user_ids =
User.where(username_lower: names.map(&:downcase)).pluck(:username, :id).transpose
found_names ||= []
user_ids ||= []
# a bit edge casey cause we have username_lower for users but not for groups
# we share a namespace though so in practice this is ok
remaining_names = names - found_names
group_ids = []
if remaining_names.present?
group_ids.concat(
Group
.visible_groups(guardian.user)
.members_visible_groups(guardian.user)
.where(name: remaining_names)
.pluck(:id),
)
end
next scope.none if user_ids.empty? && group_ids.empty?
assignment_query = Assignment.none # needed cause we are adding .or later
if user_ids.present?
assignment_query =
assignment_query.or(
Assignment.active.where(assigned_to_type: "User", assigned_to_id: user_ids),
)
end
if group_ids.present?
assignment_query =
assignment_query.or(
Assignment.active.where(assigned_to_type: "Group", assigned_to_id: group_ids),
)
end
scope.where(id: assignment_query.select(:topic_id))
end
register_modifier(:topics_filter_options) do |results, guardian|
if guardian.can_assign?
results << {
name: "assigned:",
description: I18n.t("discourse_assign.filter.description.assigned"),
type: "username_group_list",
extra_entries: [
{ name: "nobody", description: I18n.t("discourse_assign.filter.description.nobody") },
{ name: "*", description: I18n.t("discourse_assign.filter.description.anyone") },
],
priority: 1,
}
end
results
end
register_search_advanced_filter(/in:assigned/) do |posts|
next if !@guardian.can_assign?
posts.where("topics.id IN (SELECT a.topic_id FROM assignments a WHERE a.active)")
end
register_search_advanced_filter(/in:unassigned/) do |posts|
next if !@guardian.can_assign?
posts.where("topics.id NOT IN (SELECT a.topic_id FROM assignments a WHERE a.active)")
end
register_search_advanced_filter(/assigned:(.+)$/) do |posts, match|
next if !@guardian.can_assign? || match.blank?
if user_id = User.find_by_username(match)&.id
posts.where(<<~SQL, user_id)
topics.id IN (SELECT a.topic_id FROM assignments a WHERE a.assigned_to_id = ? AND a.assigned_to_type = 'User' AND a.active)
SQL
elsif group_id = Group.find_by(name: match)&.id
posts.where(<<~SQL, group_id)
topics.id IN (SELECT a.topic_id FROM assignments a WHERE a.assigned_to_id = ? AND a.assigned_to_type = 'Group' AND a.active)
SQL
end
end
if defined?(DiscourseAutomation)
add_automation_scriptable("random_assign") do
field :assignees_group, component: :group, required: true
field :assigned_topic, component: :text, required: true
field :minimum_time_between_assignments, component: :text
field :max_recently_assigned_days, component: :text
field :min_recently_assigned_days, component: :text
field :skip_new_users_for_days, component: :text
field :in_working_hours, component: :boolean
field :post_template, component: :post
version 1
triggerables %i[point_in_time recurring]
script do |context, fields, automation|
RandomAssignUtils.automation_script!(context, fields, automation)
end
end
end
if defined?(DiscourseSolved)
register_modifier(:assigns_reminder_assigned_topics_query) do |query|
next query if !SiteSetting.ignore_solved_topics_in_assigned_reminder
query.where.not(id: DiscourseSolved::SolvedTopic.select(:topic_id))
end
register_modifier(:assigned_count_for_user_query) do |query, user|
next query if !SiteSetting.ignore_solved_topics_in_assigned_reminder
next query if SiteSetting.assignment_status_on_solve.blank?
query.where.not(status: SiteSetting.assignment_status_on_solve)
end
on(:accepted_solution) do |post|
next if SiteSetting.assignment_status_on_solve.blank?
assignments = Assignment.includes(:target).where(topic: post.topic)
assignments.each do |assignment|
assigned_user = User.find_by(id: assignment.assigned_to_id)
Assigner.new(assignment.target, assigned_user).assign(
assigned_user,
status: SiteSetting.assignment_status_on_solve,
)
end
end
on(:unaccepted_solution) do |post|
next if SiteSetting.assignment_status_on_unsolve.blank?
assignments = Assignment.includes(:target).where(topic: post.topic)
assignments.each do |assignment|
assigned_user = User.find_by(id: assignment.assigned_to_id)
Assigner.new(assignment.target, assigned_user).assign(
assigned_user,
status: SiteSetting.assignment_status_on_unsolve,
)
end
end
end
end