Merge commit '580eedcab22dc7ead82134d351ef118578359935' as 'plugins/redmine_workload'
This commit is contained in:
84
plugins/redmine_workload/app/models/group_user_dummy.rb
Normal file
84
plugins/redmine_workload/app/models/group_user_dummy.rb
Normal file
@@ -0,0 +1,84 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'forwardable'
|
||||
|
||||
##
|
||||
# GroupUserDummy representing a user of a group who holds all issues haven't been assigned
|
||||
# to a real group member yet.
|
||||
#
|
||||
# @note The class name is relevant for sorting GroupUserDummy against User classes
|
||||
# in alphabetical order.
|
||||
#
|
||||
class GroupUserDummy
|
||||
include Redmine::I18n
|
||||
extend Forwardable
|
||||
|
||||
def_delegators :group, :id, :firstname, :users
|
||||
|
||||
attr_reader :group
|
||||
|
||||
##
|
||||
# @params group [Group] An instance of Group model.
|
||||
#
|
||||
def initialize(group:)
|
||||
self.group = group
|
||||
self.group_members = find_group_members
|
||||
end
|
||||
|
||||
def wl_user_data
|
||||
nil
|
||||
end
|
||||
|
||||
def groups
|
||||
[group]
|
||||
end
|
||||
|
||||
def lastname
|
||||
l(:label_assigned_to_group, value: group.lastname)
|
||||
end
|
||||
|
||||
alias name lastname
|
||||
|
||||
def threshold_lowload_min
|
||||
sum_up(:threshold_lowload_min)
|
||||
end
|
||||
|
||||
def threshold_normalload_min
|
||||
sum_up(:threshold_normalload_min)
|
||||
end
|
||||
|
||||
def threshold_highload_min
|
||||
sum_up(:threshold_highload_min)
|
||||
end
|
||||
|
||||
def main_group
|
||||
group
|
||||
end
|
||||
|
||||
def main_group_id
|
||||
group.id
|
||||
end
|
||||
|
||||
def type
|
||||
'Group'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_writer :group
|
||||
attr_accessor :group_members
|
||||
|
||||
def sum_up(attribute)
|
||||
return 0.0 unless group_members.presence
|
||||
|
||||
group_members.sum(&attribute.to_sym)
|
||||
end
|
||||
|
||||
def find_group_members
|
||||
WlUserData.where(user_id: group_member_ids, main_group: id)
|
||||
end
|
||||
|
||||
def group_member_ids
|
||||
users.map(&:id)
|
||||
end
|
||||
end
|
||||
156
plugins/redmine_workload/app/models/group_workload.rb
Normal file
156
plugins/redmine_workload/app/models/group_workload.rb
Normal file
@@ -0,0 +1,156 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Summarize the workload of a whole group and integrate its members including
|
||||
# the group user dummy.
|
||||
#
|
||||
class GroupWorkload
|
||||
attr_reader :time_span, :user_workload
|
||||
|
||||
##
|
||||
# @param users [WlUserSelection] Users given as WlUserSelection object.
|
||||
# @param user_workload [UserWorkload] User workload given as UserWorkload object.
|
||||
# @param time_span [Range] A time span given as Range object.
|
||||
#
|
||||
def initialize(users:, user_workload:, time_span:)
|
||||
self.users = users
|
||||
self.user_workload = user_workload
|
||||
self.time_span = time_span
|
||||
self.selected_groups = define_selected_groups
|
||||
self.group_members = define_group_members
|
||||
end
|
||||
|
||||
##
|
||||
# Gives all aggregated data of the group and details for each user.
|
||||
#
|
||||
# @return [Hash(Group, UserWorkload#hours_per_user_issue_and_day)] Hash with
|
||||
# results of UserWorkload#hours_per_user_issue_and_day for each group.
|
||||
def by_group
|
||||
selected_groups&.each_with_object({}) do |group, hash|
|
||||
summary = summarize_over_group_members(group)
|
||||
hash[group] = summary.merge(group_members[group])
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_accessor :users, :selected_groups, :group_members
|
||||
attr_writer :time_span, :user_workload
|
||||
|
||||
def define_selected_groups
|
||||
users.groups&.selected
|
||||
end
|
||||
|
||||
##
|
||||
# Select only those group members having their main group equal to the group
|
||||
# given.
|
||||
#
|
||||
def define_group_members
|
||||
selected_groups&.each_with_object({}) do |group, hash|
|
||||
hash[group] = sorted_user_workload.select { |user, _data| user.main_group_id == group.id }
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Sorting of users lastname and their class name in order to ensure that the
|
||||
# GroupUserDummy will come first.
|
||||
#
|
||||
def sorted_user_workload
|
||||
user_workload_with_availabilities.sort_by { |user, _data| [user.class.name, user.lastname] }.to_h
|
||||
end
|
||||
|
||||
##
|
||||
# Adds those users which are selected but not considered for the workload
|
||||
# table since they have no issues assigned yet.
|
||||
#
|
||||
def user_workload_with_availabilities
|
||||
availabilities = users.selected - assignees
|
||||
availabilities.each do |user|
|
||||
user_workload[user] = { total: total_availabilities_of(user) }
|
||||
end
|
||||
user_workload
|
||||
end
|
||||
|
||||
def total_availabilities_of(user)
|
||||
working_days = WlDateTools.working_days_in_time_span(time_span, user)
|
||||
time_span.each_with_object({}) do |day, hash|
|
||||
holiday = working_days.exclude?(day)
|
||||
capacity = WlDayCapacity.new(assignee: user)
|
||||
hash[day] = {}
|
||||
hash[day][:hours] = 0.0
|
||||
hash[day][:holiday] = holiday
|
||||
hash[day][:lowload] = capacity.threshold_at(:lowload, holiday)
|
||||
hash[day][:normalload] = capacity.threshold_at(:normalload, holiday)
|
||||
hash[day][:highload] = capacity.threshold_at(:highload, holiday)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Users having issues assigned and are therefore considered in user_workload
|
||||
# calculation.
|
||||
#
|
||||
def assignees
|
||||
user_workload.keys
|
||||
end
|
||||
|
||||
def summarize_over_group_members(group)
|
||||
{ overdue_hours: sum_of(:overdue_hours, group),
|
||||
overdue_number: sum_of(:overdue_number, group),
|
||||
unscheduled_number: sum_of(:unscheduled_number, group),
|
||||
unscheduled_hours: sum_of(:unscheduled_hours, group),
|
||||
total: total_of_group_members(group),
|
||||
invisible: invisibles_of_group_members(group) }
|
||||
end
|
||||
|
||||
def sum_of(key, group)
|
||||
group_members[group].sum { |_member, data| data[key.to_sym] || 0 }
|
||||
end
|
||||
|
||||
def total_of_group_members(group)
|
||||
time_span.each_with_object({}) do |day, hash|
|
||||
hash[day] = {}
|
||||
hash[day][:hours] = hours_at(day, :total, group)
|
||||
hash[day][:holiday] = holiday_at(day, :total, group)
|
||||
hash[day][:lowload] = threshold_at(day, :lowload, group)
|
||||
hash[day][:normalload] = threshold_at(day, :normalload, group)
|
||||
hash[day][:highload] = threshold_at(day, :highload, group)
|
||||
end
|
||||
end
|
||||
|
||||
def invisibles_of_group_members(group)
|
||||
invisible = time_span.each_with_object({}) do |day, hash|
|
||||
hours = hours_at(day, :invisible, group)
|
||||
holidays = holiday_at(day, :invisible, group)
|
||||
|
||||
hash[day] = {}
|
||||
hash[day][:hours] = hours
|
||||
hash[day][:holiday] = holidays
|
||||
end
|
||||
invisible.any? { |_date, data| data[:hours].positive? } ? invisible : nil
|
||||
end
|
||||
|
||||
def hours_at(day, key, group)
|
||||
group_members[group].sum { |_member, data| data.dig(key.to_sym, day, :hours) || 0 }
|
||||
end
|
||||
|
||||
##
|
||||
# Checks for holiday of group members including GroupUserDummy, who never will
|
||||
# be on vacation, for a given day and returns true if all group members are in
|
||||
# holiday at a given day or false if not.
|
||||
#
|
||||
def holiday_at(day, key, group)
|
||||
values = group_members[group].map do |_member, data|
|
||||
data.dig(key.to_sym, day, :holiday)
|
||||
end
|
||||
values.compact.all?
|
||||
end
|
||||
|
||||
##
|
||||
# Sums up threshold values per day and group but ignores GroupUserDummy.
|
||||
#
|
||||
def threshold_at(day, key, group)
|
||||
group_members[group].sum do |member, data|
|
||||
member.is_a?(User) ? (data.dig(:total, day, key.to_sym) || 0.0) : 0.0
|
||||
end
|
||||
end
|
||||
end
|
||||
372
plugins/redmine_workload/app/models/user_workload.rb
Normal file
372
plugins/redmine_workload/app/models/user_workload.rb
Normal file
@@ -0,0 +1,372 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Provides methods for building the workload table. These methods are used in
|
||||
# WorkloadsController and its views.
|
||||
#
|
||||
class UserWorkload
|
||||
include Redmine::I18n
|
||||
include WlIssueQuery
|
||||
include WlIssueState
|
||||
|
||||
attr_reader :assignees, :issues, :time_span, :today
|
||||
|
||||
def initialize(assignees:, time_span:, today:, issues: nil)
|
||||
self.assignees = assignees
|
||||
self.issues = open_issues_for_users(assignees, issues)
|
||||
self.time_span = time_span
|
||||
self.today = today
|
||||
end
|
||||
|
||||
##
|
||||
# Returns the hours per day in the given time span (including firstDay and
|
||||
# lastDay) for each open issue of each of the given users.
|
||||
# The result is returned as nested hash:
|
||||
# The topmost hash takes a user object as key and returns a hash that takes
|
||||
# among others a project as key.
|
||||
# The projects hash takes among others an issue as key which again
|
||||
# returns the day related data in another hash as returned by
|
||||
# UserWorkload#hours_for_issue_per_day.
|
||||
#
|
||||
# @example Returned hash for a two day time span
|
||||
#
|
||||
# { #<User id: 12, ...> => { :overdue_hours => 0.0,
|
||||
# :overdue_number => 0,
|
||||
# :total => { Sat, 12 Mar 2022 => { :hours=>0.0, :holiday=>true },
|
||||
# Sun, 13 Mar 2022 => { :hours=>0.0, :holiday=>true } },
|
||||
# :invisible => {},
|
||||
# #<Project id: 4711, ...> =>
|
||||
# { :total => { Sat, 12 Mar 2022=>{:hours=>0.0, :holiday=>true},
|
||||
# Sun, 13 Mar 2022=>{:hours=>0.0, :holiday=>true} },
|
||||
# :overdue_hours => 0.0,
|
||||
# :overdue_number => 0,
|
||||
# #<Issue id: 12176, ...> => { Sat, 12 Mar 2022 => { :hours => 0.0,
|
||||
# :active => true,
|
||||
# :noEstimate => false,
|
||||
# :holiday => true },
|
||||
# Sun, 13 Mar 2022 => { :hours => 0.0,
|
||||
# :active => true,
|
||||
# :noEstimate => false,
|
||||
# :holiday => true } } } }
|
||||
#
|
||||
# Additionally, the returned hash has two special keys:
|
||||
# * :invisible. Returns a summary of all issues that are not visible for the
|
||||
# currently logged in user.
|
||||
# ´* :total. Returns a summary of all issues for the user that this hash is
|
||||
# for.
|
||||
# @return [Hash] Hash with all relevant data for displaying the workload table
|
||||
# on user base.
|
||||
def hours_per_user_issue_and_day
|
||||
raise ArgumentError unless issues.is_a?(Array)
|
||||
raise ArgumentError unless time_span.is_a?(Range)
|
||||
raise ArgumentError unless today.is_a?(Date)
|
||||
|
||||
result = {}
|
||||
|
||||
issues.group_by(&:assigned_to).each do |assignee, issue_set|
|
||||
working_days = working_days_in_time_span(assignee: assignee)
|
||||
first_working_day_from_today_on = working_days.select { |day| day >= today }.min || today
|
||||
cap = WlDayCapacity.new(assignee: assignee)
|
||||
|
||||
assignee = GroupUserDummy.new(group: assignee) if assignee.is_a? Group
|
||||
|
||||
unless result.key?(assignee)
|
||||
result[assignee] = {
|
||||
overdue_hours: 0.0,
|
||||
overdue_number: 0,
|
||||
unscheduled_hours: 0.0,
|
||||
unscheduled_number: 0,
|
||||
total: {},
|
||||
invisible: {}
|
||||
}
|
||||
|
||||
time_span.each do |day|
|
||||
holiday = working_days.exclude?(day)
|
||||
result[assignee][:total][day] = {
|
||||
hours: 0.0,
|
||||
holiday: holiday,
|
||||
lowload: threshold_at(cap, holiday, :lowload),
|
||||
normalload: threshold_at(cap, holiday, :normalload),
|
||||
highload: threshold_at(cap, holiday, :highload)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
## Iterate over each issue in the array
|
||||
issue_set.each do |issue|
|
||||
project = issue.project
|
||||
hours_for_issue = hours_for_issue_per_day(issue, cap, assignee)
|
||||
remaining_estimated_hours = estimated_time_for_issue(issue)
|
||||
|
||||
# Add the issue to the total workload unless its overdue or unscheduled.
|
||||
# @note issue_overdue? implies there is a due_date. In order to avoid
|
||||
# double counting, a missing start_date will be ignored as criteria of
|
||||
# beeing unscheduled.
|
||||
if issue_overdue?(issue, today)
|
||||
result[assignee][:overdue_hours] += hours_for_issue[first_working_day_from_today_on][:hours]
|
||||
result[assignee][:overdue_number] += 1
|
||||
elsif issue.due_date.nil?
|
||||
result[assignee][:unscheduled_hours] += remaining_estimated_hours
|
||||
result[assignee][:unscheduled_number] += 1
|
||||
else
|
||||
result[assignee][:total] = add_issue_info_to_summary(result[assignee][:total], hours_for_issue, assignee)
|
||||
end
|
||||
|
||||
# If the issue is invisible, add it to the invisible issues summary.
|
||||
# Otherwise, add it to the project (and its summary) to which it belongs
|
||||
# to.
|
||||
if issue.visible?
|
||||
unless result[assignee].key?(project)
|
||||
result[assignee][project] = {
|
||||
total: {},
|
||||
overdue_hours: 0.0,
|
||||
overdue_number: 0,
|
||||
unscheduled_hours: 0.0,
|
||||
unscheduled_number: 0
|
||||
}
|
||||
|
||||
time_span.each do |day|
|
||||
holiday = working_days.exclude?(day)
|
||||
result[assignee][project][:total][day] = {
|
||||
hours: 0.0,
|
||||
holiday: holiday,
|
||||
lowload: threshold_at(cap, holiday, :lowload),
|
||||
normalload: threshold_at(cap, holiday, :normalload),
|
||||
highload: threshold_at(cap, holiday, :highload)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Add the issue to the project workload summary unless its overdue or unscheduled.
|
||||
# @note issue_overdue? implies there is a due_date. In order to avoid
|
||||
# double counting, a missing start_date will be ignored as criteria of
|
||||
# beeing unscheduled.
|
||||
if issue_overdue?(issue, today)
|
||||
result[assignee][project][:overdue_hours] += hours_for_issue[first_working_day_from_today_on][:hours]
|
||||
result[assignee][project][:overdue_number] += 1
|
||||
elsif issue.due_date.nil?
|
||||
result[assignee][project][:unscheduled_hours] += remaining_estimated_hours
|
||||
result[assignee][project][:unscheduled_number] += 1
|
||||
else
|
||||
result[assignee][project][:total] =
|
||||
add_issue_info_to_summary(result[assignee][project][:total], hours_for_issue, assignee)
|
||||
end
|
||||
|
||||
# Add it to the issues for that project in any case.
|
||||
result[assignee][project][issue] = hours_for_issue
|
||||
else
|
||||
unless issue_overdue?(issue, today)
|
||||
result[assignee][:invisible] =
|
||||
add_issue_info_to_summary(result[assignee][:invisible], hours_for_issue, assignee)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
alias by_user hours_per_user_issue_and_day
|
||||
|
||||
private
|
||||
|
||||
attr_writer :assignees, :issues, :time_span, :today
|
||||
|
||||
##
|
||||
# Returns the hours per day for the given issue. The result is only computed
|
||||
# for days in the given time span. The function assumes that firstDay is
|
||||
# today, so all remaining hours need to be done on or after firstDay.
|
||||
# If the issue is overdue, all hours are assigned to the first working day
|
||||
# after firstDay, or to firstDay itself, if it is a working day.
|
||||
#
|
||||
# The result is a hash taking a Date as key and returning a hash with the
|
||||
# following keys:
|
||||
# * :hours - the hours needed on that day
|
||||
# * :active - true if the issue is active on that day, false else
|
||||
# * :noEstimate - no estimated hours calculated because the issue has
|
||||
# no estimate set or either start-time or end-time are not
|
||||
# set.
|
||||
# * :holiday - true if this is a holiday, false otherwise.
|
||||
#
|
||||
# @param issue [Issue] A single issue object.
|
||||
# @param time_span [Range] Relevant time span.
|
||||
# @param today [Date] The date of today.
|
||||
#
|
||||
# @return [Hash] If the given time span is empty, an empty hash is returned.
|
||||
#
|
||||
def hours_for_issue_per_day(issue, cap, assignee)
|
||||
raise ArgumentError unless issue.is_a?(Issue)
|
||||
raise ArgumentError unless time_span.is_a?(Range)
|
||||
raise ArgumentError unless today.is_a?(Date)
|
||||
|
||||
hours_remaining = estimated_time_for_issue(issue)
|
||||
working_days = working_days_in_time_span(assignee: assignee)
|
||||
|
||||
result = {}
|
||||
|
||||
# If issue is overdue and the remaining time may be estimated, all
|
||||
# remaining hours are put on first working day.
|
||||
if !issue.due_date.nil? && (issue.due_date < today)
|
||||
|
||||
# Initialize all days to inactive
|
||||
time_span.each do |day|
|
||||
# A day is active if it is after the issue start and before the issue due date
|
||||
is_active = (day <= issue.due_date && (issue.start_date.nil? || issue.start_date >= day))
|
||||
holiday = working_days.exclude?(day)
|
||||
|
||||
result[day] = {
|
||||
hours: 0.0,
|
||||
active: is_active,
|
||||
noEstimate: false,
|
||||
holiday: holiday,
|
||||
lowload: threshold_at(cap, holiday, :lowload),
|
||||
normalload: threshold_at(cap, holiday, :normalload),
|
||||
highload: threshold_at(cap, holiday, :highload)
|
||||
}
|
||||
end
|
||||
|
||||
first_working_day_after_today = WlDateTools.working_days_in_time_span(today..time_span.end, assignee).min
|
||||
result[first_working_day_after_today] = {} if result[first_working_day_after_today].nil?
|
||||
result[first_working_day_after_today][:hours] = hours_remaining
|
||||
|
||||
# If the hours needed for an issue can not be estimated, set all days
|
||||
# outside the issues time to inactive, and all days within the issues time
|
||||
# to active but not estimated.
|
||||
elsif issue.due_date.nil? || issue.start_date.nil?
|
||||
time_span.each do |day|
|
||||
holiday = working_days.exclude?(day)
|
||||
|
||||
# Check: Is the issue is active on day?
|
||||
result[day] = if (!issue.due_date.nil? && (day <= issue.due_date)) ||
|
||||
(!issue.start_date.nil? && (day >= issue.start_date)) ||
|
||||
(issue.start_date.nil? && issue.due_date.nil?)
|
||||
|
||||
{
|
||||
hours: 0.0, # No estimate possible, use zero
|
||||
# to make other calculations easy.
|
||||
active: true,
|
||||
noEstimate: true && !holiday, # On holidays, the zero hours
|
||||
# are *not* estimated
|
||||
holiday: holiday,
|
||||
lowload: threshold_at(cap, holiday, :lowload),
|
||||
normalload: threshold_at(cap, holiday, :normalload),
|
||||
highload: threshold_at(cap, holiday, :highload)
|
||||
}
|
||||
|
||||
# Issue is not active
|
||||
else
|
||||
{
|
||||
hours: 0.0, # Not active => 0 hours to do.
|
||||
active: false,
|
||||
noEstimate: false,
|
||||
holiday: holiday,
|
||||
lowload: threshold_at(cap, holiday, :lowload),
|
||||
normalload: threshold_at(cap, holiday, :normalload),
|
||||
highload: threshold_at(cap, holiday, :highload)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# The issue has start and end date
|
||||
else
|
||||
# Number of remaining working days for the issue:
|
||||
remaining_time_span = [today, issue.start_date].max..issue.due_date
|
||||
number_of_workdays_for_issue = WlDateTools.real_distance_in_days(remaining_time_span, assignee)
|
||||
hours_per_workday = hours_remaining / number_of_workdays_for_issue.to_f
|
||||
|
||||
time_span.each do |day|
|
||||
holiday = working_days.exclude?(day)
|
||||
|
||||
result[day] = if (day >= issue.start_date) && (day <= issue.due_date)
|
||||
|
||||
if day >= today
|
||||
hours = holiday ? 0.0 : hours_per_workday
|
||||
{
|
||||
hours: hours,
|
||||
active: true,
|
||||
noEstimate: issue.estimated_hours.nil? && !holiday,
|
||||
holiday: holiday,
|
||||
lowload: threshold_at(cap, holiday, :lowload),
|
||||
normalload: threshold_at(cap, holiday, :normalload),
|
||||
highload: threshold_at(cap, holiday, :highload)
|
||||
}
|
||||
else
|
||||
{
|
||||
hours: 0.0,
|
||||
active: true,
|
||||
noEstimate: false,
|
||||
holiday: holiday,
|
||||
lowload: threshold_at(cap, holiday, :lowload),
|
||||
normalload: threshold_at(cap, holiday, :normalload),
|
||||
highload: threshold_at(cap, holiday, :highload)
|
||||
}
|
||||
end
|
||||
else
|
||||
{
|
||||
hours: 0.0,
|
||||
active: false,
|
||||
noEstimate: false,
|
||||
holiday: holiday,
|
||||
lowload: threshold_at(cap, holiday, :lowload),
|
||||
normalload: threshold_at(cap, holiday, :normalload),
|
||||
highload: threshold_at(cap, holiday, :highload)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
##
|
||||
# Calculates the issues estimated hours weighted by its unfinished ratio.
|
||||
#
|
||||
# @param issue [Issue] The issue object with relevant estimated hours.
|
||||
# @return [Float] The decimal number of remaining working hours.
|
||||
#
|
||||
#
|
||||
def estimated_time_for_issue(issue)
|
||||
raise ArgumentError unless issue.is_a?(Issue)
|
||||
|
||||
return 0.0 if issue.estimated_hours.nil?
|
||||
return 0.0 if issue.children.any? && !consider_parent_issues?
|
||||
|
||||
issue.estimated_hours * ((100.0 - issue.done_ratio) / 100.0)
|
||||
end
|
||||
|
||||
##
|
||||
# Prepares a summary of issue infos.
|
||||
#
|
||||
# @param summary
|
||||
# @param issue_info
|
||||
#
|
||||
def add_issue_info_to_summary(summary, issue_info, assignee)
|
||||
summary ||= {}
|
||||
|
||||
time_span.each do |day|
|
||||
holiday = { hours: 0.0, holiday: working_days_in_time_span(assignee: assignee).exclude?(day) }
|
||||
summary[day] = holiday unless summary.key?(day)
|
||||
summary[day][:hours] += issue_info[day][:hours]
|
||||
end
|
||||
|
||||
summary
|
||||
end
|
||||
|
||||
##
|
||||
# Collects all working days within a given time span.
|
||||
#
|
||||
def working_days_in_time_span(assignee:, no_cache: false)
|
||||
WlDateTools.working_days_in_time_span(time_span, assignee, no_cache: no_cache)
|
||||
end
|
||||
|
||||
##
|
||||
# Calculates the day and user dependent threshold value of the workload.
|
||||
#
|
||||
# @param cap [WlDayCapacity] An object able to calculate the workload day capacity.
|
||||
# @param holiday [Boolean] Either a true or false value.
|
||||
# @param key [Symbol|String] The short form of the threshold: lowload, normalload, highload.
|
||||
#
|
||||
def threshold_at(cap, holiday, key)
|
||||
cap.threshold_at(key, holiday)
|
||||
end
|
||||
end
|
||||
41
plugins/redmine_workload/app/models/wl_day_capacity.rb
Normal file
41
plugins/redmine_workload/app/models/wl_day_capacity.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Calculates day and user dependent workload threshold values
|
||||
#
|
||||
class WlDayCapacity
|
||||
##
|
||||
# @param assignee [User|Group|GroupUserDummy|String|Integer] Can handle several
|
||||
# objects but should
|
||||
# be User, Group or
|
||||
# GroupUserDummy.
|
||||
#
|
||||
def initialize(**params)
|
||||
self.assignee = params[:assignee]
|
||||
end
|
||||
|
||||
def threshold_at(key, holiday)
|
||||
return 0.0 if assignee == 'unassigned' || assignee.is_a?(Integer)
|
||||
|
||||
holiday ? 0.0 : user.send("threshold_#{key}_min")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_accessor :assignee
|
||||
|
||||
##
|
||||
# Check what kind of assignee should be used.
|
||||
#
|
||||
def user
|
||||
@user ||= assignee.is_a?(User) ? single_user(assignee) : group_user(assignee)
|
||||
end
|
||||
|
||||
def single_user(assignee)
|
||||
assignee.wl_user_data || WlDefaultUserData.new
|
||||
end
|
||||
|
||||
def group_user(assignee)
|
||||
GroupUserDummy.new(group: assignee)
|
||||
end
|
||||
end
|
||||
20
plugins/redmine_workload/app/models/wl_default_user_data.rb
Normal file
20
plugins/redmine_workload/app/models/wl_default_user_data.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Holds default user related data for workload calculation.
|
||||
#
|
||||
class WlDefaultUserData
|
||||
include WlUserDataDefaults
|
||||
|
||||
def threshold_lowload_min
|
||||
default_attributes[:threshold_lowload_min].to_f
|
||||
end
|
||||
|
||||
def threshold_normalload_min
|
||||
default_attributes[:threshold_normalload_min].to_f
|
||||
end
|
||||
|
||||
def threshold_highload_min
|
||||
default_attributes[:threshold_highload_min].to_f
|
||||
end
|
||||
end
|
||||
79
plugins/redmine_workload/app/models/wl_group_selection.rb
Normal file
79
plugins/redmine_workload/app/models/wl_group_selection.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Presenter organising groups to be used in views/workloads/_filers.erb.
|
||||
#
|
||||
class WlGroupSelection
|
||||
##
|
||||
# @param groups [Array(Group)] List of Group objects.
|
||||
# @param user [User] A user object.
|
||||
#
|
||||
# @note params[:user] is currently used for tests only!
|
||||
def initialize(**params)
|
||||
self.groups = params[:groups] || []
|
||||
self.user = define_user(params[:user])
|
||||
end
|
||||
|
||||
##
|
||||
# Returns selected groups when allowed to be viewed by the user.
|
||||
#
|
||||
# @return [Array(Group)] An array of group objects.
|
||||
def selected
|
||||
groups_by_params & allowed_to_display
|
||||
end
|
||||
|
||||
##
|
||||
# Prepares groups to be used as selection in filters.
|
||||
#
|
||||
def allowed_to_display
|
||||
groups_allowed_to_display.sort_by { |group| group[:lastname] }
|
||||
end
|
||||
|
||||
def all_group_ids
|
||||
all_groups.map(&:id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_accessor :user, :groups
|
||||
|
||||
##
|
||||
# Define the current user.
|
||||
#
|
||||
def define_user(user)
|
||||
user || User.current
|
||||
end
|
||||
|
||||
##
|
||||
# Queries the groups the user is allowed to view.
|
||||
# @return [Array(Group)] List of group objects. The list is empty if the user
|
||||
# is not allowed to view any group.
|
||||
#
|
||||
def groups_allowed_to_display
|
||||
return all_groups if user.admin? || allowed_to?(:view_all_workloads)
|
||||
|
||||
return own_groups if allowed_to?(:view_own_group_workloads)
|
||||
|
||||
[]
|
||||
end
|
||||
|
||||
def all_groups
|
||||
Group.includes(users: :wl_user_data).distinct.all.to_a
|
||||
end
|
||||
|
||||
def own_groups
|
||||
user.groups.to_a
|
||||
end
|
||||
|
||||
def groups_by_params
|
||||
Group.joins(users: :wl_user_data).distinct.where(id: group_ids).to_a
|
||||
end
|
||||
|
||||
def group_ids
|
||||
groups.map(&:to_i)
|
||||
end
|
||||
|
||||
def allowed_to?(permission)
|
||||
user.allowed_to?(permission.to_sym, nil, global: true)
|
||||
end
|
||||
end
|
||||
27
plugins/redmine_workload/app/models/wl_national_holiday.rb
Normal file
27
plugins/redmine_workload/app/models/wl_national_holiday.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WlNationalHoliday < ActiveRecord::Base
|
||||
unloadable
|
||||
|
||||
validates :start, date: true
|
||||
validates :end, date: true
|
||||
validates :start, :end, :reason, presence: true
|
||||
validate :check_datum
|
||||
|
||||
after_destroy :clearCache
|
||||
after_save :clearCache
|
||||
|
||||
def check_datum
|
||||
errors.add :end, :greater_than_start_date if workload_end_before_start?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def workload_end_before_start?
|
||||
start && self.end && (start_changed? || end_changed?) && self.end < start
|
||||
end
|
||||
|
||||
def clearCache
|
||||
Rails.cache.clear
|
||||
end
|
||||
end
|
||||
36
plugins/redmine_workload/app/models/wl_user_data.rb
Normal file
36
plugins/redmine_workload/app/models/wl_user_data.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Holds user related data for workload calculation.
|
||||
#
|
||||
class WlUserData < ActiveRecord::Base
|
||||
include WlUserDataDefaults
|
||||
|
||||
belongs_to :user, inverse_of: :wl_user_data, optional: true
|
||||
self.table_name = 'wl_user_datas'
|
||||
|
||||
validates :threshold_lowload_min, :threshold_normalload_min, :threshold_highload_min, presence: true
|
||||
validate :selected_group
|
||||
|
||||
def self.own_groups(user_object = User.current)
|
||||
user_object.groups
|
||||
end
|
||||
|
||||
def update_to_defaults_when_new
|
||||
return unless new_record?
|
||||
|
||||
update(default_attributes)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def selected_group
|
||||
return if main_group.blank? || own_group?(user)
|
||||
|
||||
errors.add(:main_group, :inclusion)
|
||||
end
|
||||
|
||||
def own_group?(user)
|
||||
self.class.own_groups(user).pluck(:id).include? main_group
|
||||
end
|
||||
end
|
||||
153
plugins/redmine_workload/app/models/wl_user_selection.rb
Normal file
153
plugins/redmine_workload/app/models/wl_user_selection.rb
Normal file
@@ -0,0 +1,153 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Presenter organising users to be used in views/workloads/_filers.erb.
|
||||
#
|
||||
class WlUserSelection
|
||||
attr_reader :groups
|
||||
|
||||
##
|
||||
# @param users [Array(User)] Selected user objects.
|
||||
# @param group_selection [WlGroupSelection] WlGroupSelection object.
|
||||
# @param user [User] A user object.
|
||||
#
|
||||
# @note params[:user] is currently used for tests only!
|
||||
#
|
||||
def initialize(**params)
|
||||
self.users = params[:users] || []
|
||||
self.groups = params[:group_selection]
|
||||
self.selected_groups = groups&.selected
|
||||
self.user = define_user(params[:user])
|
||||
end
|
||||
|
||||
def all_selected
|
||||
selected_groups | selected
|
||||
end
|
||||
|
||||
##
|
||||
# Returns selected users when allowed to be viewed by the given user.
|
||||
#
|
||||
# @return [Array(User)] An array of user objects.
|
||||
def selected
|
||||
(users_from_context & allowed_to_display) | include_current_user
|
||||
end
|
||||
|
||||
##
|
||||
# Prepares users to be used in filters
|
||||
# @return [Array(User)] An array of user objects.
|
||||
def allowed_to_display
|
||||
users_allowed_to_display.sort_by(&:lastname)
|
||||
end
|
||||
|
||||
def all_user_ids
|
||||
all_users.map(&:id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_accessor :user, :users, :selected_groups
|
||||
attr_writer :groups
|
||||
|
||||
##
|
||||
# Define the current user.
|
||||
#
|
||||
def define_user(user)
|
||||
user || User.current
|
||||
end
|
||||
|
||||
##
|
||||
# It is expected to return the current user only if the user visits the
|
||||
# workload index page but not if she hasn't selected herself in the filter
|
||||
# fields afterwards.
|
||||
#
|
||||
def include_current_user
|
||||
return [user] if users_from_context.blank?
|
||||
|
||||
[]
|
||||
end
|
||||
|
||||
##
|
||||
# If groups are given the method will query those users having one of the given
|
||||
# groups as main group. If no groups are given the users_by_params will be
|
||||
# returned instead.
|
||||
#
|
||||
# @return [Array(User)] An array of user objects.
|
||||
#
|
||||
def users_from_context
|
||||
selected_users = users_of_groups | users_by_params
|
||||
return users_by_params if selected_groups.blank?
|
||||
|
||||
selected_users.select do |user|
|
||||
selected_groups.map(&:id).include? user.main_group_id
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Collects all users across all projects where the given user has the permission
|
||||
# to view the project workload.
|
||||
#
|
||||
# @param [User] An optional single user object. Default: User.current.
|
||||
# @return [Array(User)] Array of all users objects the current user may display.
|
||||
#
|
||||
def users_allowed_to_display
|
||||
return all_users if user.admin? || allowed_to?(:view_all_workloads)
|
||||
|
||||
result = group_members_allowed_to(:view_own_group_workloads)
|
||||
|
||||
if result.blank?
|
||||
result = allowed_to?(:view_own_workloads) ? [user] : []
|
||||
end
|
||||
|
||||
result.flatten.uniq
|
||||
end
|
||||
|
||||
def all_users
|
||||
all = User.joins(:groups).distinct
|
||||
return all.joins(:wl_user_data).active if selected_groups.present?
|
||||
|
||||
all.active
|
||||
end
|
||||
|
||||
##
|
||||
# Get all active users of groups where the current user has a membership.
|
||||
#
|
||||
# @param permission [String|Symbol] Permission name.
|
||||
# @return [Array(User)] An array of user objects.
|
||||
#
|
||||
# @note user.groups does not return the user itself as group member!
|
||||
#
|
||||
def group_members_allowed_to(permission)
|
||||
return [] unless allowed_to?(permission)
|
||||
|
||||
user.groups.map(&:users)
|
||||
end
|
||||
|
||||
##
|
||||
# Queries all active users as given by workload params.
|
||||
#
|
||||
# @return [Array(User)] An array of user objects.
|
||||
def users_by_params
|
||||
all_users.where(id: user_ids).to_a
|
||||
end
|
||||
|
||||
##
|
||||
# Collects all users belonging to selected groups if the user is still active.
|
||||
#
|
||||
# @return [Array(User)] An array of user objects.
|
||||
#
|
||||
def users_of_groups
|
||||
return [] if selected_groups.blank?
|
||||
|
||||
result = selected_groups.map { |group| group.users.select(&:active?) }
|
||||
result.flatten!
|
||||
result.uniq
|
||||
end
|
||||
|
||||
def user_ids
|
||||
users.map(&:to_i)
|
||||
end
|
||||
|
||||
def allowed_to?(permission)
|
||||
user.allowed_to?(permission.to_sym, nil, global: true)
|
||||
end
|
||||
end
|
||||
30
plugins/redmine_workload/app/models/wl_user_vacation.rb
Normal file
30
plugins/redmine_workload/app/models/wl_user_vacation.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WlUserVacation < ActiveRecord::Base
|
||||
unloadable
|
||||
|
||||
belongs_to :user, inverse_of: :wl_user_vacations, optional: true
|
||||
|
||||
validates :date_from, date: true
|
||||
validates :date_to, date: true
|
||||
|
||||
validates :date_from, :date_to, presence: true
|
||||
validate :check_datum
|
||||
|
||||
after_destroy :clearCache
|
||||
after_save :clearCache
|
||||
|
||||
def check_datum
|
||||
errors.add :date_to, :greater_than_start_date if workload_end_before_start?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def workload_end_before_start?
|
||||
date_from && date_to && (date_from_changed? || date_to_changed?) && date_to < date_from
|
||||
end
|
||||
|
||||
def clearCache
|
||||
Rails.cache.clear
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user