Skip to content

Commit

Permalink
FEATURE: Add a fallback to auto-assign
Browse files Browse the repository at this point in the history
Currently, when the auto-assign logic can’t find a user to assign, it
will fail saying there was no one to assign. The current logic is this
one:
- Don’t pick anyone who’s been picked in the last 180 days
- If no one has been found, then try the same thing but only for the
  last 14 days.
While this is working relatively well for large enough groups, it
doesn’t work at all with very small groups (like 1 or 2 people) and it
creates unnecessary noise.

This patch addresses this issue by adding a fallback to the current
logic. Now, if the two first rules fail, instead of saying that no one
was assigned, we assign the least recently assigned person. This way,
the logic will continue to work with large groups but will also work
nicely with small groups.
  • Loading branch information
Flink committed Nov 16, 2023
1 parent da20a2b commit 70ff57b
Show file tree
Hide file tree
Showing 2 changed files with 343 additions and 333 deletions.
271 changes: 157 additions & 114 deletions lib/random_assign_utils.rb
Original file line number Diff line number Diff line change
@@ -1,130 +1,189 @@
# frozen_string_literal: true

class RandomAssignUtils
def self.raise_error(automation, message)
raise("[discourse-automation id=#{automation.id}] #{message}.")
end
attr_reader :context, :fields, :automation, :topic, :group

def self.log_info(automation, message)
Rails.logger.info("[discourse-automation id=#{automation.id}] #{message}.")
def self.automation_script!(...)
new(...).automation_script!
end

def self.automation_script!(context, fields, automation)
raise_error(automation, "discourse-assign is not enabled") unless SiteSetting.assign_enabled?
def initialize(context, fields, automation)
@context = context
@fields = fields
@automation = automation

raise_error("discourse-assign is not enabled") unless SiteSetting.assign_enabled?
unless topic_id = fields.dig("assigned_topic", "value")
raise_error(automation, "`assigned_topic` not provided")
end

unless topic = Topic.find_by(id: topic_id)
raise_error(automation, "Topic(#{topic_id}) not found")
raise_error("`assigned_topic` not provided")
end

min_hours = fields.dig("minimum_time_between_assignments", "value").presence
if min_hours &&
TopicCustomField
.where(name: "assigned_to_id", topic_id: topic_id)
.where("created_at < ?", min_hours.to_i.hours.ago)
.exists?
log_info(automation, "Topic(#{topic_id}) has already been assigned recently")
return
unless @topic = Topic.find_by(id: topic_id)
raise_error("Topic(#{topic_id}) not found")
end

unless group_id = fields.dig("assignees_group", "value")
raise_error(automation, "`assignees_group` not provided")
raise_error("`assignees_group` not provided")
end

unless group = Group.find_by(id: group_id)
raise_error(automation, "Group(#{group_id}) not found")
unless @group = Group.find_by(id: group_id)
raise_error("Group(#{group_id}) not found")
end
end

assignable_user_ids = User.assign_allowed.pluck(:id)
users_on_holiday =
Set.new(
User.where(
id: UserCustomField.where(name: "on_holiday", value: "t").select(:user_id),
).pluck(:id),
def automation_script!
return log_info("Topic(#{topic.id}) has already been assigned recently") if assigned_recently?
return no_one! unless assigned_user
assign_user!
end

def recently_assigned_users_ids(from)
usernames =
PostCustomField
.joins(:post)
.where(
name: "action_code_who",
posts: {
topic: topic,
action_code: %w[assigned reassigned assigned_to_post],
},
)
.where("posts.created_at > ?", from)
.order("posts.created_at DESC")
.pluck(:value)
.uniq
User
.where(username: usernames)
.joins(
"JOIN unnest('{#{usernames.join(",")}}'::text[]) WITH ORDINALITY t(username, ord) USING(username)",
)
.limit(100)
.pluck(:id)
end

group_users = group.group_users.joins(:user)
if skip_new_users_for_days = fields.dig("skip_new_users_for_days", "value").presence
group_users = group_users.where("users.created_at < ?", skip_new_users_for_days.to_i.days.ago)
end
private

def assigned_user
@assigned_user ||=
begin
group_users_ids = group_users.pluck(:id)
return if group_users_ids.empty?

last_assignees_ids = recently_assigned_users_ids(max_recently_assigned_days)
users_ids = group_users_ids - last_assignees_ids
if users_ids.blank?
recently_assigned_users_ids = recently_assigned_users_ids(min_recently_assigned_days)
users_ids = group_users_ids - recently_assigned_users_ids
end
users_ids << last_assignees_ids.last if users_ids.blank?
if fields.dig("in_working_hours", "value")
assign_to_user_id = users_ids.shuffle.detect { |user_id| in_working_hours?(user_id) }
end
assign_to_user_id ||= users_ids.sample

User.find(assign_to_user_id)
end
end

group_users_ids =
group_users
.pluck("users.id")
.filter { |user_id| assignable_user_ids.include?(user_id) }
.reject { |user_id| users_on_holiday.include?(user_id) }
def assign_user!
return create_post_template if post_template
Assigner
.new(topic, Discourse.system_user)
.assign(assigned_user)
.then do |result|
next if result[:success]
no_one!
end
end

if group_users_ids.empty?
RandomAssignUtils.no_one!(topic_id, group.name)
return
end
def create_post_template
post =
PostCreator.new(
Discourse.system_user,
raw: post_template,
skip_validations: true,
topic_id: topic.id,
).create!
Assigner
.new(post, Discourse.system_user)
.assign(assigned_user)
.then do |result|
next if result[:success]
PostDestroyer.new(Discourse.system_user, post).destroy
no_one!
end
end

max_recently_assigned_days =
(fields.dig("max_recently_assigned_days", "value").presence || 180).to_i.days.ago
last_assignees_ids =
RandomAssignUtils.recently_assigned_users_ids(topic_id, max_recently_assigned_days)
users_ids = group_users_ids - last_assignees_ids
if users_ids.blank?
min_recently_assigned_days =
(fields.dig("min_recently_assigned_days", "value").presence || 14).to_i.days.ago
recently_assigned_users_ids =
RandomAssignUtils.recently_assigned_users_ids(topic_id, min_recently_assigned_days)
users_ids = group_users_ids - recently_assigned_users_ids
end
def group_users
users =
group
.users
.where(id: User.assign_allowed.select(:id))
.where.not(
id:
User
.joins(:_custom_fields)
.where(user_custom_fields: { name: "on_holiday", value: "t" })
.select(:id),
)
return users unless skip_new_users_for_days
users.where("users.created_at < ?", skip_new_users_for_days)
end

if users_ids.blank?
RandomAssignUtils.no_one!(topic_id, group.name)
return
end
def raise_error(message)
raise("[discourse-automation id=#{automation.id}] #{message}.")
end

if fields.dig("in_working_hours", "value")
assign_to_user_id =
users_ids.shuffle.find { |user_id| RandomAssignUtils.in_working_hours?(user_id) }
end
def log_info(message)
Rails.logger.info("[discourse-automation id=#{automation.id}] #{message}.")
end

assign_to_user_id ||= users_ids.sample
if assign_to_user_id.blank?
RandomAssignUtils.no_one!(topic_id, group.name)
return
end
def no_one!
PostCreator.create!(
Discourse.system_user,
topic_id: topic.id,
raw: I18n.t("discourse_automation.scriptables.random_assign.no_one", group: group.name),
validate: false,
)
end

assign_to = User.find(assign_to_user_id)
result = nil
if raw = fields.dig("post_template", "value").presence
post =
PostCreator.new(
Discourse.system_user,
raw: raw,
skip_validations: true,
topic_id: topic.id,
).create!

result = Assigner.new(post, Discourse.system_user).assign(assign_to)

PostDestroyer.new(Discourse.system_user, post).destroy if !result[:success]
else
result = Assigner.new(topic, Discourse.system_user).assign(assign_to)
end
def assigned_recently?
return unless min_hours
TopicCustomField
.where(name: "assigned_to_id", topic: topic)
.where("created_at < ?", min_hours)
.exists?
end

RandomAssignUtils.no_one!(topic_id, group.name) if !result[:success]
def skip_new_users_for_days
days = fields.dig("skip_new_users_for_days", "value").presence
return unless days
days.to_i.days.ago
end

def self.recently_assigned_users_ids(topic_id, from)
posts =
Post
.joins(:user)
.where(topic_id: topic_id, action_code: %w[assigned reassigned assigned_to_post])
.where("posts.created_at > ?", from)
.order(created_at: :desc)
usernames =
Post.custom_fields_for_ids(posts, [:action_code_who]).map { |_, v| v["action_code_who"] }.uniq
User.where(username: usernames).limit(100).pluck(:id)
def max_recently_assigned_days
@max_days ||= (fields.dig("max_recently_assigned_days", "value").presence || 180).to_i.days.ago
end

def self.user_tzinfo(user_id)
def min_recently_assigned_days
@min_days ||= (fields.dig("min_recently_assigned_days", "value").presence || 14).to_i.days.ago
end

def post_template
@post_template ||= fields.dig("post_template", "value").presence
end

def min_hours
hours = fields.dig("minimum_time_between_assignments", "value").presence
return unless hours
hours.to_i.hours.ago
end

def in_working_hours?(user_id)
tzinfo = user_tzinfo(user_id)
tztime = tzinfo.now

!tztime.saturday? && !tztime.sunday? && tztime.hour > 7 && tztime.hour < 11
end

def user_tzinfo(user_id)
timezone = UserOption.where(user_id: user_id).pluck(:timezone).first || "UTC"

tzinfo = nil
Expand All @@ -140,20 +199,4 @@ def self.user_tzinfo(user_id)

tzinfo
end

def self.no_one!(topic_id, group)
PostCreator.create!(
Discourse.system_user,
topic_id: topic_id,
raw: I18n.t("discourse_automation.scriptables.random_assign.no_one", group: group),
validate: false,
)
end

def self.in_working_hours?(user_id)
tzinfo = RandomAssignUtils.user_tzinfo(user_id)
tztime = tzinfo.now

!tztime.saturday? && !tztime.sunday? && tztime.hour > 7 && tztime.hour < 11
end
end
Loading

0 comments on commit 70ff57b

Please sign in to comment.