Squashed 'plugins/redmine_workload/' content from commit f94bb00

git-subtree-dir: plugins/redmine_workload
git-subtree-split: f94bb00e5eb387a55379714b8f58fb6f35517174
This commit is contained in:
2023-03-24 11:34:26 +01:00
commit 580eedcab2
108 changed files with 6493 additions and 0 deletions

106
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@@ -0,0 +1,106 @@
name: Bug Report
description: File a bug report
title: "[Bug]: "
labels: ["bug"]
assignees:
- liaham
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
id: contact
attributes:
label: Contact Details
description: How can we get in touch with you if we need more info?
placeholder: ex. email@example.com
validations:
required: false
- type: textarea
id: expected-behavior
attributes:
label: What did you expect?
description: Please tell us, what did you expect to happen?
placeholder: Tell us what should have happend!
value:
validations:
required: true
- type: textarea
id: current-behavior
attributes:
label: What has happend instead?
description: Please tell us, what has happend instead?
placeholder: Tell us what has happend!
value:
validations:
required: true
- type: textarea
id: possible-solution
attributes:
label: What could be a possible solution?
description: Please tell us, how do you think the problem could be solved?
placeholder: Tell us what is your idea for a possible solution!
value:
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: How can we reproduce the problem?
description: Please tell us, what steps to execute to reproduce the issue.
placeholder: Tell us step by step what we need to do!
value:
validations:
required: true
- type: textarea
id: environment
attributes:
label: In what environment are you running the plugin?
description: Please copy and paste your environment information as displayed in Administration » Information or run `bin/about` in the root dir of your Redmine instance.
placeholder: Paste your environment information here!
value:
validations:
required: true
- type: dropdown
id: version
attributes:
label: Version
description: What version of our plugin are you running?
options:
- 1.0.0
- 1.0.1
- 1.0.2
- 1.0.3
- 1.1.0
- 2.0.0
- 2.0.1
- 2.0.2
- 2.1.0
- 2.2.0
- 2.2.1 (Latest)
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this issue, you agree to follow our [Code of Conduct](https://circle.xmera.de/projects/contributors-guide/wiki/Code-of-conduct)
options:
- label: I agree to follow this project's Code of Conduct
required: true

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
/.project
/.loadpath
/config/additional_environment.rb
/config/configuration.yml
/config/database.yml
/config/email.yml
/config/initializers/session_store.rb
/coverage
/db/*.db
/db/*.sqlite3
/db/schema.rb
/files/*
/lib/redmine/scm/adapters/mercurial/redminehelper.pyc
/lib/redmine/scm/adapters/mercurial/redminehelper.pyo
/log/*.log*
/log/mongrel_debug
/public/dispatch.*
/public/plugin_assets
/tmp/*
/tmp/cache/*
/tmp/sessions/*
/tmp/sockets/*
/tmp/test/*
/vendor/rails
*.rbc
/nbproject
.history

29
.hgignore Normal file
View File

@@ -0,0 +1,29 @@
syntax: glob
.project
.loadpath
config/additional_environment.rb
config/configuration.yml
config/database.yml
config/email.yml
config/initializers/session_store.rb
coverage
db/*.db
db/*.sqlite3
db/schema.rb
files/*
lib/redmine/scm/adapters/mercurial/redminehelper.pyc
lib/redmine/scm/adapters/mercurial/redminehelper.pyo
log/*.log*
log/mongrel_debug
public/dispatch.*
public/plugin_assets
tmp/*
tmp/cache/*
tmp/sessions/*
tmp/sockets/*
tmp/test/*
vendor/rails
*.rbc
.svn/
.git/

59
.rubocop.yml Normal file
View File

@@ -0,0 +1,59 @@
inherit_from: .rubocop_todo.yml
AllCops:
NewCops: enable
DisplayCopNames: true
DisplayStyleGuide: true
TargetRubyVersion: 2.7
Exclude:
- '**/vendor/**/*'
- '**/tmp/**/*'
- '**/bin/**/*'
- '**/extra/**/*'
- '**/lib/generators/**/templates/*'
- '**/lib/tasks/**/*'
- '**/files/**/*'
- '**/test/**/*'
- '**/db/migrate/*'
- 'db/schema.rb'
require:
- rubocop-performance
- rubocop-rails
Style/FrozenStringLiteralComment:
Enabled: true
EnforcedStyle: always
Exclude:
- 'db/**/*.rb'
- 'Gemfile'
- 'Rakefile'
Metrics/AbcSize:
Exclude:
- 'test/**/*'
Metrics/MethodLength:
Exclude:
- 'test/**/*'
Layout/LineLength:
Max: 120
Style/Documentation:
Enabled: false
Style/Encoding:
Enabled: false
Layout/TrailingWhitespace:
AllowInHeredoc: true
Rails/ApplicationRecord:
Enabled: false
Rails/OutputSafety:
Exclude:
- 'app/helpers/workload_filters_helper.rb'
- 'app/helpers/workloads_helper.rb'

66
.rubocop_todo.yml Normal file
View File

@@ -0,0 +1,66 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2022-05-25 15:57:22 UTC using RuboCop version 1.28.2.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
# Offense count: 10
# Configuration parameters: IgnoredMethods, CountRepeatedAttributes.
Metrics/AbcSize:
Max: 114
# Offense count: 4
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods.
# IgnoredMethods: refine
Metrics/BlockLength:
Max: 75
# Offense count: 1
# Configuration parameters: CountBlocks.
Metrics/BlockNesting:
Max: 4
# Offense count: 1
# Configuration parameters: CountComments, CountAsOne.
Metrics/ClassLength:
Max: 215
# Offense count: 4
# Configuration parameters: IgnoredMethods.
Metrics/CyclomaticComplexity:
Max: 28
# Offense count: 15
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods.
Metrics/MethodLength:
Max: 95
# Offense count: 2
# Configuration parameters: IgnoredMethods.
Metrics/PerceivedComplexity:
Max: 32
# Offense count: 3
# Configuration parameters: EnforcedStyle, AllowedPatterns, IgnoredPatterns.
# SupportedStyles: snake_case, camelCase
Naming/MethodName:
Exclude:
- 'app/controllers/workloads_controller.rb'
- 'app/models/wl_national_holiday.rb'
- 'app/models/wl_user_vacation.rb'
# Offense count: 4
# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
# AllowedNames: at, by, db, id, in, io, ip, of, on, os, pp, to
Naming/MethodParameterName:
Exclude:
- 'app/helpers/workload_filters_helper.rb'
# Offense count: 8
# Configuration parameters: EnforcedStyle, AllowedIdentifiers.
# SupportedStyles: snake_case, camelCase
Naming/VariableName:
Exclude:
- 'app/helpers/workload_filters_helper.rb'

75
CHANGELOG.md Normal file
View File

@@ -0,0 +1,75 @@
# Changelog for Redmine Workload
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.2.1 - 2023-02-17
### Fixed
* nil error in data.keys.sort for very large time spans
## 2.2.0 - 2023-01-19
### Changed
* how to decide when an issue is overdue by comparing with a given date
## 2.1.0 - 2022-12-09
### Added
* Plugin setting 'workload_of_parent_issues' as option to include parent issues
in the workload calculation
## 2.0.2 - 2022-11-14
### Added
* support for Redmine 5 with backward compatability to Redmine 4
* translations for some permissions
### Fixed
* nil error in WorkloadsHelper#load_class_for_hour
* nil error when user enters conflicting dates
## 2.0.1 - 2022-06-21
### Fixed
* undefined method 'id' in GroupWorkload#total_availabilities_of
## 2.0.0 - 2022-06-07
### Added
* week numbers to workload table header
* group issues to workload table if a group is selected
* calculation of group workload based on user main group setting
* presentation of summarized group workload in workload table
* additional infos about unscheduled issues
* permissions :view_all_workloads, :view_own_group_workloads, :view_own_workloads
* csv export of users or groups
### Changed
* using of dynamic action segments in routes due to deprecation warning
* styling of workload table to look similar as gantt diagram
* user and group selection to be in a separate class to make it reusable
* permissions to be global again, i.e., not dependend of project module enabled
* display of current user to show only if visited workload index page or when
selected explicitly
* error messages to translate field names
### Fixed
* broken unit test
* missing closing selectors in some views causing the site footer to be displayed
not at the bottom of the page
---
**NOTE** Changes prior and equal to version 1.1.0 are not reported.

113
README.md Normal file
View File

@@ -0,0 +1,113 @@
# Workload Plugin for Redmine
A complete rewrite of the original workload-plugin from Rafael Calleja.
The plugin calculates how much work each user would have to do per day in order to hit the deadlines for all his issues.
It also calculates this information for a [group](https://www.redmine.org/projects/redmine/wiki/RedmineGroups).
It calculates issues (number and hours) that are behind schedule and calculates issues that are unplanned (number and hours) so far.
To be able to do all this calculations, the issues start date, due date and estimated time must be filled in.
Issues that have not filled in one of these fields will be shown in the overview, but the workload resulting from these issues will be ignored.
![Group Workload](screenshots/group-workload-example.png?raw=true "Group Workload Example")
## New Features in Version 2.2.0
### how to decide if an issue is overdue
Redefines the overdue state of an issue. Instead of comparing issue.due_date with the User.current.today (as in Issue#overdue?) it will compare by the
date given by the user.
This change allows a workload analysis independent of the current date leading to more meaningful scenarios.
## New Features in Version 2.1.0
### consider workload of parent issues
By default parent issues are ignored when calculating workloads. With this setting the administrator can change the default behaviour by considering also all parent issues in the calculation.
## New Features in Version 2.0.x
Fortunately the German company [MENTOR GmbH & Co. Präzisions-Bauteile KG](https://www.mentor.de.com/) invested in this project to make these features possible:
### support of Redmine 5
Version 2.0.2 supports Redmine 5 and is backward compatible with Redmine 4.
### style-rework
The actual table has been a bit bulky and sticked out from the formatting of other areas. Especially when using themes like [Purplemine](https://github.com/mrliptontea/PurpleMine2).
Now the css has been reworked to make the style more compact and gantt-like.
### workload per group
This introduces a new level of information for issues adressed to [groups](https://www.redmine.org/projects/redmine/wiki/RedmineGroups).
It now can show informations about issues adressed to a group and calculates the workload of this group.
To avoid missleading informations therefore each user needs to define the group where he/she puts his/her effort in.
### unplanned issues
The Plugin now calculates "unplanned" issues. This applys to issues that dont have a `start date` or a `due date`.
The result now is shown close to overdue issues.
### export
The only way to have a look on the data was the workload page.
There has been no way to transfer data, e.g. to excel, to draw some charts.
Now there is a feature to export the workload per user and per role to build charts external.
## Installation / Uninstallation
Please refer to [redmine.org -> Plugins](https://www.redmine.org/projects/redmine/wiki/Plugins)
## How it Works
![Workload Calculation Process](screenshots/workload_calculation.png?raw=true "Workload Caclulation Process")
## Configuration
There are three places where this plugin might be configured:
1. In the plugin settings, available in the administration area under `plugins`.
You can configure working days, thresholds here and set global holidays.
2. In the roles section of the administration area, the plugin adds new permissions as described below.
There is no need to configure this plugin on project level.
3. On the workload page each user can setup his vacations.
Here thresholds can be set divergent to global configuration. The `main group` needs to be set.
## Permissions
The plugin shows the workload as follows:
* An *admin user* can see the workload of everyone and configure user independent settings.
* Any normal user can **see** workload as configured per role under project permissions:
- *view own workloads*: a role with this permission can see own workload
- *view own group workloads*: a role with this permission can see workloads of all users in his/her configured `main group`
- *view all workloads*: a role with this permission can see workload of everyone.
- When showing the issues that contribute to the workload, only issues visible to the current user are shown. Invisible issues are only summarized.
* Any normal user can **configure** own settings as set per role under project permissions:
- *Edit national Holidays*
- *Edit own vacations*
- *Edit own workload thresholds*
## Holidays, Vacation and User Workload Data
National holidays and user vacation is counted as day off (like weekend).
Admins can setup National Holidays in plugin settings.
Users can get permissions to setup their vacations and workload data with 'Roles and permissions'.
You can specify user(s), who should be able to setup national holidays with 'Roles and permissions'.
## CSV-Export
Here you can export the values that are shown in the browser to use it in other systems (e.g. draw charts).
|Column|possible values|description|
|------|---------------|-----------|
|Status|planned, available|Describes if this Line shows planned workload or available hours per day.|
|Type|aggregation, group, user|Describes if this Line shows hours for one `user`, hours that assigned to a `group` or hours that are `aggregated` for the group.|
|Main group|*group-name*|Reports the configured `main group` for a `user` and (implicit) for a `group`. Is empty in case of aggregation.|
|Number of overdue issues|*number*|Number of issues that are behind schedule.|
|Hours of overdue issues|*hours*|Aggregated hours of issues that are behind schedule.|
|Number of unplanned issues|*number*|Number of issues that are unplanned.|
|Hours of unplanned issues|*hours*|Aggregated hours of issues that are unplanned.|
|..date..|*datum* and *hours*|Column is named from the belonging datum. Lists per line the hours per day.|

View File

@@ -0,0 +1,93 @@
# frozen_string_literal: true
require 'json'
class WlNationalHolidayController < ApplicationController
include WlUserDataFinder
before_action :authorize_global, only: %i[create update destroy]
before_action :find_user_workload_data
before_action :select_year
helper :workloads
def index
filter_year_start = Date.new(@this_year, 0o1, 0o1)
filter_year_end = Date.new(@this_year, 12, 31)
@wl_national_holiday = WlNationalHoliday.where('start between ? AND ?', filter_year_start, filter_year_end)
@is_allowed = User.current.allowed_to_globally?(:edit_national_holiday)
end
def new; end
def edit
@wl_national_holiday = begin
WlNationalHoliday.find(params[:id])
rescue StandardError
nil
end
end
def create
@wl_national_holiday = WlNationalHoliday.new(wl_national_holiday_params)
if @wl_national_holiday.save
flash[:notice] = l(:notice_holiday_saved)
redirect_to action: 'index', year: params[:year]
else
respond_to do |format|
format.html do
render :new
end
format.api { render_validation_errors(@wl_national_holiday) }
end
end
end
def update
@wl_national_holiday = begin
WlNationalHoliday.find(params[:id])
rescue StandardError
nil
end
respond_to do |format|
if @wl_national_holiday.update(wl_national_holiday_params)
format.html do
flash[:notice] = l(:notice_holiday_updated)
redirect_to(action: 'index', params: { year: params[:year] })
end
format.xml { head :ok }
else
format.html do
render action: 'edit'
end
format.xml { render xml: @wl_national_holiday.errors, status: :unprocessable_entity }
end
end
end
def destroy
@wl_national_holiday = begin
WlNationalHoliday.find(params[:id])
rescue StandardError
nil
end
@wl_national_holiday.destroy
flash[:notice] = l(:notice_holiday_deleted)
redirect_to(action: 'index', year: params[:year])
end
private
def select_year
if params[:year]
@this_year = params[:year].to_i
elsif @this_year.blank?
@this_year = Time.zone.today.strftime('%Y').to_i
end
end
def wl_national_holiday_params
params.require(:wl_national_holiday).permit(:start, :end, :reason)
end
end

View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
class WlUserDatasController < ApplicationController
include WlUserDataFinder
helper :workloads
helper :wl_user_datas
before_action :authorize_global, only: %i[update]
before_action :find_user_workload_data, only: %i[edit update]
def edit
@is_allowed = User.current.allowed_to_globally?(:edit_user_data)
end
def update
respond_to do |format|
if @user_workload_data.update(wl_user_data_params)
format.html do
flash[:notice] = l(:notice_settings_updated)
redirect_to workloads_path
end
format.xml { head :ok }
else
format.html do
render :edit
end
format.xml { render xml: @user_workload_data.errors, status: :unprocessable_entity }
end
end
end
private
def wl_user_data_params
params.require(:wl_user_data).permit(:user_id, :threshold_lowload_min, :threshold_normalload_min,
:threshold_highload_min, :main_group)
end
end

View File

@@ -0,0 +1,90 @@
# frozen_string_literal: true
class WlUserVacationsController < ApplicationController
include WlUserDataFinder
helper :workloads
before_action :authorize_global, only: %i[create update destroy]
before_action :find_user_workload_data
def index
@is_allowed = User.current.allowed_to_globally?(:edit_user_vacations)
@wl_user_vacations = User.current.wl_user_vacations
end
def new; end
def edit
@wl_user_vacation = begin
WlUserVacation.find(params[:id])
rescue StandardError
nil
end
end
def create
@wl_user_vacation = WlUserVacation.new(wl_user_vacations_params)
@wl_user_vacation.user_id = User.current.id
respond_to do |format|
if @wl_user_vacation.save
format.html do
flash[:notice] = l(:notice_user_vacation_saved)
redirect_to(action: 'index', params: { year: params[:year] })
end
else
format.html do
render action: 'new'
end
format.api { render_validation_errors(@wl_user_vacation) }
end
end
end
def update
@wl_user_vacation = begin
WlUserVacation.find(params[:id])
rescue StandardError
nil
end
respond_to do |format|
if @wl_user_vacation.update(wl_user_vacation_params)
format.html do
flash[:notice] = l(:notice_user_vacation_saved)
redirect_to(action: 'index', params: { year: params[:year] })
end
else
format.html do
render action: 'edit'
end
format.xml { render xml: @wl_user_vacation.errors, status: :unprocessable_entity }
end
end
end
def destroy
@wl_user_vacation = begin
WlUserVacation.find(params[:id])
rescue StandardError
nil
end
@wl_user_vacation.destroy
respond_to do |format|
format.html do
flash[:notice] = l(:notice_user_vacation_deleted)
redirect_to(action: 'index', params: { year: params[:year] })
end
end
end
private
def wl_user_vacations_params
params.require(:wl_user_vacations).permit(:user_id, :date_from, :date_to, :comments, :vacation_type)
end
def wl_user_vacation_params
params.require(:wl_user_vacation).permit(:id, :user_id, :date_from, :date_to, :comments, :vacation_type)
end
end

View File

@@ -0,0 +1,92 @@
# frozen_string_literal: true
class WorkloadsController < ApplicationController
unloadable
helper :gantt
helper :issues
helper :projects
helper :queries
helper :workload_filters
helper :workloads
include QueriesHelper
include WlUserDataFinder
include WorkloadsHelper
before_action :authorize_global, only: %i[index]
before_action :find_user_workload_data
def index
@first_day = sanitizeDateParameter(workload_params[:first_day], Time.zone.today - 10)
@last_day = sanitizeDateParameter(workload_params[:last_day], Time.zone.today + 50)
@today = sanitizeDateParameter(workload_params[:start_date], Time.zone.today)
@date_check = @last_day >= @first_day
# if @today ("select as today") is before @first_day take @today as @first_day
@first_day = [@today, @first_day].min
# Make sure that last_day is at most 12 months after first_day to prevent
# long running times
@last_day = [(@first_day >> 12) - 1, @last_day].min
@time_span_to_display = @first_day..@last_day
if @date_check
@groups = WlGroupSelection.new(groups: workload_params[:groups])
@users = WlUserSelection.new(users: workload_params[:users], group_selection: @groups)
assignees = @users.all_selected
user_workload = UserWorkload.new(assignees: assignees,
time_span: @time_span_to_display,
today: @today)
@months_to_render = WlDateTools.months_in_time_span(@time_span_to_display)
@workload_data = user_workload.hours_per_user_issue_and_day
@group_workload = GroupWorkload.new(users: @users,
user_workload: @workload_data,
time_span: @time_span_to_display)
@workload = groups?(@groups) ? @group_workload : user_workload
end
respond_to do |format|
format.html do
flash.now[:error] = l(:error_date_setting) unless @date_check
render action: :index
end
format.csv do
send_data(workloads_to_csv(@workload, params), type: 'text/csv; header=present', filename: 'workload.csv')
end
end
end
private
##
# Prepares workload params based on params[:workload] and params[:filter_type]
# where the latter is relevant for exporting the data via csv.
#
def workload_params
wl_params = params[:workload]&.merge(filter_type: params[:filter_type]) || {}
return wl_params if wl_params[:filter_type].blank?
wl_params.merge(assignee_ids)
end
def assignee_ids
filter = params[:filter_type]&.first
return if filter.blank?
groups = filter.include? 'groups'
groups ? { groups: WlGroupSelection.new.all_group_ids } : { users: WlUserSelection.new.all_user_ids }
end
def sanitizeDateParameter(parameter, default)
if parameter.respond_to?(:to_date)
parameter.to_date
else
default
end
end
end

View File

@@ -0,0 +1,12 @@
# frozen_string_literal: true
##
# Provides some helper methods for WlUserData related forms.
#
module WlUserDatasHelper
def user_groups_for_select(selected:)
options = []
options += WlUserData.own_groups.pluck(:lastname, :id)
options_for_select(options, selected)
end
end

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
module WorkloadFiltersHelper
def user_options_for_select(usersToShow, selectedUsers)
result = ''
return unless usersToShow
usersToShow.each do |user|
selected = selectedUsers.include?(user) ? 'selected="selected"' : ''
result += "<option value=\"#{h(user.id)}\" #{selected}>#{h(user.name)}</option>"
end
result.html_safe
end
def group_options_for_select(groupsToShow, selectedGroups)
result = ''
return unless groupsToShow
groupsToShow.each do |group|
selected = selectedGroups.include?(group) ? 'selected="selected"' : ''
result += "<option value=\"#{h(group&.id)}\" #{selected}>#{h(group.lastname)}</option>"
end
result.html_safe
end
end

View File

@@ -0,0 +1,124 @@
# frozen_string_literal: true
module WorkloadsHelper
def render_action_links
render partial: 'wl_shared/action_links'
end
##
# Writes the css class for a group, user, and project combined.
# @see css_group_class
# @see css_user_class
# @see css_project_class
#
def css_group_user_project_class(group_id, user_id, project_id)
"#{css_group_class(group_id)} #{css_user_class(user_id)} #{css_project_class(project_id)}"
end
##
# Writes the css class for a project.
# @param project_id [Integer] The project id.
#
def css_project_class(project_id)
return unless project_id
"project-#{project_id}"
end
##
# Writes the css class for a group and user combined.
# @see css_group_class
# @see css_user_class
#
def css_group_user_class(group_id, user_id)
"#{css_group_class(group_id)} #{css_user_class(user_id)}"
end
##
# Writes the css class for a group.
# @param group_id [Integer] The group id.
#
def css_group_class(group_id)
return unless group_id
"group-#{group_id}"
end
##
# Writes the css class for a user.
# @param user_id [Integer] The user id.
#
def css_user_class(user_id)
return unless user_id
"user-#{user_id}"
end
##
# Determines the css class for hours in dependence of the workload level.
#
# @param hours [Float] The decimal number of hours.
# @param lowload [Float] The threshold lowload min value.
# @param normalload [Float] The threshold normalload min value.
# @param highload [Float] The threshold highload min value.
# @return [String] The css class for highlighting the hours in the workload table.
#
def load_class_for_hours(hours, lowload, normalload, highload)
hours = hours.to_f
if lowload && hours < lowload
'none'
elsif normalload && hours < normalload
'low'
elsif highload && hours < highload
'normal'
else
'high'
end
end
def groups?(groups)
return false unless groups
groups.selected.presence
end
def filter_type(groups)
groups?(groups) ? 'groups' : 'users'
end
def workloads_to_csv(workload, params)
prepare = WlCsvExporter.new(data: workload, params: params)
Redmine::Export::CSV.generate(encoding: params[:encoding]) do |csv|
csv << prepare.header_fields
prepare.group_workload.each do |level, data|
csv << prepare.line(level, data, :available) if level.instance_of? Group
csv << prepare.line(level, data, :planned)
end
prepare.user_workload.each do |level, data|
csv << prepare.line(level, data, :planned)
end
csv
end
end
def workload_params_as_hidden_field_tags(params)
tags = ''
params[:workload]&.each do |key, value|
tags += if value.is_a? Array
array_to_hidden_field(key, value)
else
hidden_field_tag("workload[#{key}]", value)
end
end
tags.html_safe
end
def array_to_hidden_field(key, value)
tags = ''
value.each do |entry|
tags += hidden_field_tag("workload[#{key}][]", entry)
end
tags
end
end

View 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

View 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
app/models/user_workload.rb Normal file
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,145 @@
<%
# This file provides configuration options for the workload plugin.
%>
<fieldset class="box tabular">
<legend><%= l(:workload_settings_general_workdays) %></legend>
<p><%= l(:workload_settings_general_workdays_explanation) %></p>
<p>
<label><%= l(:workload_settings_general_workdays_monday) %></label>
<input type="hidden"
name="settings[general_workday_monday]"
value=""
>
<input type="checkbox"
name="settings[general_workday_monday]"
value="checked"
<%= "checked" if settings['general_workday_monday'] != '' %>
>
</p>
<p>
<label><%= l(:workload_settings_general_workdays_tuesday) %></label>
<input type="hidden"
name="settings[general_workday_tuesday]"
value=""
>
<input type="checkbox"
name="settings[general_workday_tuesday]"
value="checked"
<%= "checked" if settings['general_workday_tuesday'] != '' %>
>
</p>
<p>
<label><%= l(:workload_settings_general_workdays_wednesday) %></label>
<input type="hidden"
name="settings[general_workday_wednesday]"
value=""
>
<input type="checkbox"
name="settings[general_workday_wednesday]"
value="checked"
<%= "checked" if settings['general_workday_wednesday'] != '' %>
>
</p>
<p>
<label><%= l(:workload_settings_general_workdays_thursday) %></label>
<input type="hidden"
name="settings[general_workday_thursday]"
value=""
>
<input type="checkbox"
name="settings[general_workday_thursday]"
value="checked"
<%= "checked" if settings['general_workday_thursday'] != '' %>
>
</p>
<p>
<label><%= l(:workload_settings_general_workdays_friday) %></label>
<input type="hidden"
name="settings[general_workday_friday]"
value=""
>
<input type="checkbox"
name="settings[general_workday_friday]"
value="checked"
<%= "checked" if settings['general_workday_friday'] != '' %>
>
</p>
<p>
<label><%= l(:workload_settings_general_workdays_saturday) %></label>
<input type="hidden"
name="settings[general_workday_saturday]"
value=""
>
<input type="checkbox"
name="settings[general_workday_saturday]"
value="checked"
<%= "checked" if settings['general_workday_saturday'] != '' %>
>
</p>
<p>
<label><%= l(:workload_settings_general_workdays_sunday) %></label>
<input type="hidden"
name="settings[workload_of_parent_issues]"
value=""
>
<input type="checkbox"
name="settings[workload_of_parent_issues]"
value="checked"
<%= "checked" if settings['workload_of_parent_issues'] != '' %>
>
</p>
</fieldset>
<fieldset class="box tabular">
<legend><%= l(:workload_settings_hours) %></legend>
<p>
<%= l(:workload_settings_leastdailyworkload) %>
</p>
<p>
<label><%= l(:workload_settings_hours_low_min) %></label>
<input type="text"
name="settings[threshold_lowload_min]"
value="<%= settings['threshold_lowload_min'] %>"
>
</p>
<p>
<label><%= l(:workload_settings_hours_normal_min) %></label>
<input type="text"
name="settings[threshold_normalload_min]"
value="<%= settings['threshold_normalload_min'] %>"
>
</p>
<p>
<label><%= l(:workload_settings_hours_high_min) %></label>
<input type="text"
name="settings[threshold_highload_min]"
value="<%= settings['threshold_highload_min'] %>"
>
</p>
</fieldset>
<fieldset class="box">
<legend><%=l(:workload_holiday_title) %></legend>
<%= link_to l(:workload_settings_holiday_setup), :controller => 'wl_national_holiday', :action => "index" %>
</fieldset>
<fieldset class='box'>
<legend><%= l(:label_workload_calculation) %></legend>
<p>
<label><%= l(:label_include_parent_tasks) %></label>
<input type="hidden"
name="settings[workload_of_parent_issues]"
value=""
>
<input type="checkbox"
name="settings[workload_of_parent_issues]"
value="checked"
<%= "checked" if settings['workload_of_parent_issues'] != '' %>
>
<em class='info'><%= l(:info_include_parent_tasks) %></em>
<em class='info icon icon-warning'><%= l(:warning_include_parent_tasks) %></em>
</p>
</fieldset>

View File

@@ -0,0 +1,16 @@
<p>
<label><%= l(:field_start_date) %>:</label><br>
<%= form.date_select :start, default: { year: @this_year} %>
</p>
<p>
<label><%= l(:workload_user_vacation_date_end) %>:</label><br>
<%= form.date_select :end, default: { year: @this_year} %>
</p>
<p>
<label><%= l(:workload_holiday_reason) %>:</label><br>
<%= form.text_field :reason %>
</p>
<input type="hidden"
name="year"
value="<%=@this_year%>">

View File

@@ -0,0 +1,30 @@
<div class="autoscroll">
<table class="list">
<thead><tr>
<th><%= translate 'id' %></th>
<th><%= translate 'start' %></th>
<th><%= translate 'end' %></th>
<th><%= translate 'reason' %></th>
<% if is_allowed %>
<th colspan="2"></th>
<% end %>
</tr></thead>
<tbody>
<% for holiday in wl_national_holiday -%>
<tr class="<%= cycle("odd", "even") %>">
<td><%= holiday.id.to_s %></td>
<td><%= holiday.start.blank? ? '' : holiday.start.to_date.to_s %></td>
<td><%= holiday.end.blank? ? '' : holiday.end.to_date.to_s %></td>
<td><%= holiday.reason.blank? ? '' : holiday.reason %></td>
<% if is_allowed %>
<td><%= link_to l(:button_edit), edit_wl_national_holiday_path(holiday, :year => @this_year) %></td>
<td><%= link_to l(:button_delete), wl_national_holiday_path(holiday, :year => @this_year),
method: :delete,
data: { confirm: l(:text_are_you_sure) } %></td>
<% end %>
</tr>
<% end -%>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,10 @@
<%= error_messages_for 'wl_national_holiday' %>
<%= form_for @wl_national_holiday do |f| %>
<%= render(partial: "form", locals: {form: f}) %>
<p>
<%= f.submit l(:button_update)%>
<%= link_to l(:button_cancel), :controller => 'wl_national_holiday', :action => "index", :year => @this_year %>
</p>
<% end %>

View File

@@ -0,0 +1,19 @@
<div class="contextual">
<%= render_action_links %>
<%= call_hook(:view_wl_menu_extension) %>
</div>
<h2><%= l(:workload_holiday_title)%></h2>
<%= link_to l(:label_new), {controller: "wl_national_holiday", action: "new"}, class: "icon icon-add" if @is_allowed%>
<p id="year-nav" >
<%= link_to "<<", :controller => 'wl_national_holiday', :action => "index", :year => @this_year-1 %>
<%= @this_year %>
<%= link_to ">>", :controller => 'wl_national_holiday', :action => "index", :year => @this_year+1 %>
</p>
<% unless @wl_national_holiday.empty?%>
<%= render(partial: "show_list", locals: {wl_national_holiday: @wl_national_holiday, is_allowed: @is_allowed}) %>
<%end%>

View File

@@ -0,0 +1,12 @@
<%= error_messages_for 'wl_national_holiday' %>
<h1><%= l(:workload_settings_holiday_new)%></h1>
<%= form_for :wl_national_holiday, url: {action: "create"}, html: {class: "nifty_form"} do |f| %>
<%= render(partial: "form", locals: {form: f}) %>
<p>
<%= f.submit l(:button_add)%>
<%= link_to l(:button_cancel), :controller => 'wl_national_holiday', :action => "index", :year => @this_year %>
</p>
<% end %>

View File

@@ -0,0 +1,4 @@
<%= link_to(l(:workload_title), controller: 'workloads', action: 'index') %>
<%= link_to(l(:workload_holiday_title), controller: 'wl_national_holiday', action: 'index') %>
<%= link_to(l(:workload_user_data_title), edit_wl_user_data_path(@user_workload_data)) %>
<%= link_to(l(:workload_user_vacation_menu), controller: 'wl_user_vacations', action: 'index') %>

View File

@@ -0,0 +1 @@
<% # Nothing yet %>

View File

@@ -0,0 +1,18 @@
<p>
<%
label_symbol = "workload_settings_general_workdays_" + workday
# this is for testing only, needs to be initialized from model
@user_working_days[ workday ] = ''
%>
<label><%= l(label_symbol.to_sym) %></label>
<input type="hidden"
name="settings[<%= workday %>]"
value=""
>
<input type="checkbox"
name="settings[<%= workday %>]"
value="checked"
<%= "checked" if @user_working_days[ workday ] != '' %>
>
</p>

View File

@@ -0,0 +1,57 @@
<%= error_messages_for 'user_workload_data' %>
<div class="contextual">
<%= render_action_links %>
</div>
<% html_title(l(:workload_user_data_site_title)) %>
<h2><%= l(:workload_user_data_site_title) %> » <%= User.current.name %></h2>
<%= form_for @user_workload_data,
url: wl_user_data_path,
html: { :id => 'my_workload_user_data_form' } do |f| %>
<div class="splitcontentleft">
<fieldset class="box tabular">
<legend><%= l(:workload_settings_hours) %></legend>
<p>
<%= l(:workload_settings_leastdailyworkload) %>
</p>
<p>
<label><%= l(:workload_settings_hours_low_min) %></label>
<%= f.text_field :threshold_lowload_min, disabled: !@is_allowed %>
<%= l(:field_default_value)%>: <%= Setting['plugin_redmine_workload']['threshold_lowload_min']%>
</p>
<p>
<label><%= l(:workload_settings_hours_normal_min) %></label>
<%= f.text_field :threshold_normalload_min, disabled: !@is_allowed %>
<%= l(:field_default_value)%>: <%= Setting['plugin_redmine_workload']['threshold_normalload_min']%>
</p>
<p>
<label><%= l(:workload_settings_hours_high_min) %></label>
<%= f.text_field :threshold_highload_min, disabled: !@is_allowed %>
<%= l(:field_default_value)%>: <%= Setting['plugin_redmine_workload']['threshold_highload_min']%>
</p>
<p>
<label><%= l(:workload_settings_main_group) %></label>
<%= f.select :main_group,
user_groups_for_select(selected: @user_workload_data.main_group),
{ include_blank: true, disabled: !@is_allowed } %>
</p>
</fieldset>
<p class="mobile-hide">
<% if @is_allowed %>
<%= submit_tag l(:button_save) %>
<% end %>
<%= link_to l(:button_cancel), :back %>
</p>
</div>
<% end %>
<% content_for :sidebar do %>
<%= render :partial => 'sidebar' %>
<% end %>

View File

@@ -0,0 +1,21 @@
<p>
<label><%= l(:field_start_date) %>:</label><br>
<%= form.date_select :date_from, default: { year: @this_year} %>
</p>
<p>
<label><%= l(:workload_user_vacation_date_end) %>:</label><br>
<%= form.date_select :date_to, default: { year: @this_year} %>
</p>
<p>
<label><%= l(:workload_user_vacation_comments) %>:</label><br>
<%= form.text_field :comments %>
</p>
<p>
<label><%= l(:workload_user_vacation_type) %>:</label><br>
<%= form.text_field :vacation_type %>
</p>
<input type="hidden"
name="year"
value="<%=@this_year%>">

View File

@@ -0,0 +1,30 @@
<div class="autoscroll">
<table class="list">
<thead><tr>
<th><%= translate 'start' %></th>
<th><%= translate 'end' %></th>
<th><%= l(:workload_user_vacation_type) %></th>
<th><%= l(:field_comments) %></th>
<% if is_allowed %>
<th colspan="2"></th>
<% end %>
</tr></thead>
<tbody>
<% for vacation in wl_user_vacations -%>
<tr class="<%= cycle("odd", "even") %>">
<td><%= vacation.date_from.blank? ? '' : vacation.date_from.to_date.to_s %></td>
<td><%= vacation.date_to.blank? ? '' : vacation.date_to.to_date.to_s %></td>
<td><%= vacation.vacation_type.blank? ? '' : vacation.vacation_type %></td>
<td><%= vacation.comments.blank? ? '' : vacation.comments %></td>
<% if is_allowed %>
<td><%= link_to l(:button_edit), edit_wl_user_vacation_path(vacation) %></td>
<td><%= link_to l(:button_delete), wl_user_vacation_path(vacation),
method: :delete,
data: { confirm: l(:text_are_you_sure) } %></td>
<% end %>
</tr>
<% end -%>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,11 @@
<%= error_messages_for 'wl_user_vacation' %>
<h2><%= l(:workload_user_vacation_edit)%></h2>
<%= form_for @wl_user_vacation do |f| %>
<%= render(partial: "form", locals: {form: f}) %>
<p>
<%= f.submit l(:button_update)%>
<%= link_to l(:button_cancel), :controller => 'wl_user_vacations', :action => "index", :year => @this_year %>
</p>
<% end %>

View File

@@ -0,0 +1,11 @@
<div class="contextual">
<%= render_action_links %>
</div>
<h2><%= l(:workload_user_vacation_site_title) %> » <%= User.current.name %></h2>
<%= link_to l(:label_new), new_wl_user_vacation_path, :class => 'icon icon-add' if @is_allowed %>
<% unless @wl_user_vacations.empty?%>
<%= render(partial: "show_list", locals: {wl_user_vacations: @wl_user_vacations, is_allowed: @is_allowed}) %>
<%end%>

View File

@@ -0,0 +1,12 @@
<%= error_messages_for 'wl_user_vacation' %>
<h2><%= l(:workload_user_vacation_new)%></h2>
<%= form_for :wl_user_vacations, url: {action: "create"}, html: {class: "nifty_form"} do |f| %>
<%= render(partial: "form", locals: {form: f}) %>
<p>
<%= f.submit l(:button_add)%>
<%= link_to l(:button_cancel), :controller => 'wl_user_vacations', :action => "index", :year => @this_year %>
</p>
<% end %>

View File

@@ -0,0 +1,19 @@
<%
# Parameters:
# dayOfMonth: Any day of the month to render the header for.
%>
<% @time_span_to_display&.each do |currentDay|%>
<%
if (currentDay.day == 1) then
klass = ' firstDayOfMonth'
elsif (currentDay.day == currentDay.end_of_month.day) then
klass = ' lastDayOfMonth'
else
klass = ''
end
%>
<th class="day-of-month<%= klass %>" scope="col">
<%= tag.small currentDay.day.to_s %>
</th>
<% end %>

View File

@@ -0,0 +1,19 @@
<%
# Parameters:
# dayOfWeek: Any day of the week to render the header for.
%>
<% @time_span_to_display&.each do |currentDay|%>
<%
if (currentDay.cwday == 1) then
klass = ' firstDayOfWeek'
elsif (currentDay.cwday == 7) then
klass = ' lastDayOfWeek'
else
klass = ''
end
%>
<th class="workload_hdr day-of-week <%= klass %>" scope="col">
<%= tag.small day_name(currentDay.cwday).first %>
</th>
<% end %>

View File

@@ -0,0 +1,23 @@
<% other_formats_links do |f| %>
<%= f.link_to_with_query_parameters 'CSV', {}, :onclick => "showModal('csv-export-options', '330px'); return false;" %>
<% end %>
<div id="csv-export-options" style="display: none;">
<h3 class="title"><%= l(:label_export_options, :export_format => 'CSV') %></h3>
<%= form_tag(workloads_path(:format => 'csv'), :method => :get, :id => 'csv-export-form') do %>
<%= workload_params_as_hidden_field_tags(params) %>
<p>
<% if User.current.allowed_to_globally? :view_all_workloads %>
<label><%= radio_button_tag 'filter_type[]', '', true %> <%= l("description_selected_#{filter_type(groups)}") %></label><br />
<label><%= radio_button_tag 'filter_type[]', "all_#{filter_type(groups)}" %> <%= l("description_all_#{filter_type(groups)}") %></label>
<% else %>
<%= hidden_field_tag 'filter_type[]', '' %>
<% end %>
</p>
<%= export_csv_encoding_select_tag %>
<p class="buttons">
<%= submit_tag l(:button_export), :name => nil, :onclick => "hideModal(this);", :data => { :disable_with => false }, id: 'csv-export-button' %>
<%= link_to_function l(:button_cancel), "hideModal(this);" %>
</p>
<% end %>
</div>

View File

@@ -0,0 +1,43 @@
<%= form_tag({}, {:method => :get, :id => 'filter_form', :class => 'filters'}) do %>
<h3><%=l(:workload_title)%> <%=l(:workload_show_filters) %></h3>
<fieldset>
<legend><%= l(:workload_show_range) %></legend>
<div class="timespan">
<table>
<tr>
<td>
<%= label_tag :workload_first_day, l(:workload_show_rangefrom) %>
</td>
<td>
<%= text_field_tag :workload_first_day, @first_day, :name => 'workload[first_day]', :size => 10 %>
<%= calendar_for('workload_first_day') %>
</td>
</tr>
<tr>
<td><%= label_tag :workload_last_day, l(:workload_show_rangeto) %></td>
<td><%= text_field_tag :workload_last_day, @last_day, :name => 'workload[last_day]', :size => 10 %><%= calendar_for('workload_last_day') %></td>
</tr>
<tr>
<td><%= label_tag :workload_start_date, l(:workload_show_today) %></td>
<td><%= text_field_tag :workload_start_date, @today, :name => 'workload[start_date]', :size => 10 %><%= calendar_for('workload_start_date') %></td>
</tr>
</table>
</div>
</fieldset>
<br>
<fieldset>
<legend><%= l(:workload_show_filter_user_legend) %></legend>
<div class="users">
<%= label_tag :workload_users, l(:workload_show_filter_user) %>
<%= select_tag :workload_users, user_options_for_select(@users&.allowed_to_display, @users&.selected), :name => 'workload[users][]', :multiple => true, :onchange => "this.form.workload_groups.selectedIndex=-1;" %>
</div>
<br>
<div class="groups">
<%= label_tag :workload_groups, l(:workload_show_filter_group) %>
<%= select_tag :workload_groups, group_options_for_select(@groups&.allowed_to_display, @groups&.selected), :name => 'workload[groups][]', :multiple => true, :onchange => "this.form.workload_users.selectedIndex=-1;" %>
</div>
<%= link_to_function l(:button_apply), 'jQuery("#filter_form").submit()', :class => 'apply icon icon-checked' %>
</fieldset>
<% end %>

View File

@@ -0,0 +1,11 @@
<%
# Renders the headers of the month names
%>
<% @months_to_render&.each do |month| %>
<th class="workload_hdr" colspan="<%= (month[:last_day].day - month[:first_day].day) + 1 %>" scope="colgroup">
<% if ((month[:last_day] - month[:first_day]) + 1) >= 5 then %>
<%= tag.small "#{month_name(month[:first_day].month)} #{month[:first_day].year}" %>
<% end %>
</th>
<% end %>

View File

@@ -0,0 +1,19 @@
<%
# Parameters:
# num_of_week: Any week of the year to render the header for.
%>
<% @time_span_to_display&.each do |current_day|%>
<%
if (current_day.cwday == 1) then
klass = ' first-day-of-week'
elsif (current_day.cwday == 7) then
klass = ' last-day-of-week'
else
klass = ''
end
%>
<th class="num-of-week<%= klass %>" scope="col">
<%= tag.small current_day.cweek if current_day.cwday == 4 %>
</th>
<% end %>

View File

@@ -0,0 +1,27 @@
<%
# Renders the workload data for one single issue.
# Parameters:
# * assignee: The user or group to render the data for.
# * summarizedWorkload Hash that contains the summarized workload for invisible issues.
%>
<% summarizedWorkload.keys.sort.each do |day| %>
<%
hours = summarizedWorkload[day][:hours]
holiday = summarizedWorkload[day][:holiday]
lowload = summarizedWorkload[day][:lowload]
normalload = summarizedWorkload[day][:normalload]
highload = summarizedWorkload[day][:highload]
klass = 'hours'
klass += ' holiday' if holiday
klass += ' today' if @today === day
klass += ' ' + load_class_for_hours(hours, lowload, normalload, highload)
hoursString = (hours.abs < 0.01) ? '' : sprintf("%.1f", hours, assignee)
%>
<td class="<%= klass %>">
<span>
<%= hoursString %>
</span>
</td>
<% end %>

View File

@@ -0,0 +1,26 @@
<%
# Renders an accumulated workload.
# Parameters:
# * totalWorkload: Hash that contains the total workload for each day.
%>
<% totalWorkload.keys.sort.each do |day| %>
<%
hours = totalWorkload[day][:hours]
holiday = totalWorkload[day][:holiday]
lowload = totalWorkload[day][:lowload]
normalload = totalWorkload[day][:normalload]
highload = totalWorkload[day][:highload]
klass = 'hours'
klass += ' holiday' if holiday
klass += ' workingday' if !holiday
klass += ' today' if @today === day
klass += ' ' + load_class_for_hours(hours, lowload, normalload, highload)
%>
<td class="<%= klass %>">
<span>
<%= sprintf("%.1f", hours) %>
</span>
</td>
<% end %>

View File

@@ -0,0 +1,9 @@
<%#
# Creates a trigger to open and close parts of the workload view.
# Parameters:
# trigger_for: set as "data-for"-attribute
#
# &#x25b6; is a right-pointing filled triangle.
%>
<span class="trigger closed" data-for="<%= trigger_for %>">&#x25b6;</span>

View File

@@ -0,0 +1,43 @@
<%
# Renders the workload data for a single group.
# Parameters:
# * group: The group to render the data for.
# * data: The data to render. A hash with issues as keys.
%>
<tbody class="group-total-workload" id="group-total-workload-<%= group&.id %>">
<tr>
<th class="group-description <%= css_group_class(group&.id) %>" scope="row" title="<%= l(:workload_trigger_tooltip) %>">
<%= render :partial => 'trigger', :locals => {:trigger_for => css_group_class(group&.id)} %>
<%= "#{group.firstname} #{group.lastname}" %>
<% if data[:overdue_number]&.positive? || data[:unscheduled_number]&.positive? %>
<dl class="additional-group-info">
<dt><%= l(:workload_overdue_issues_num) %></dt>
<dd><%= data[:overdue_number] %></dd>
<dt><%= l(:workload_overdue_issues_hours) %></dt>
<dd><%= "%0.2f" % data[:overdue_hours] %></dd>
<dt class='mt-5'><%= l(:workload_unscheduled_issues_num) %></dt>
<dd class='mt-5'><%= data[:unscheduled_number] %></dd>
<dt><%= l(:workload_unscheduled_issues_hours) %></dt>
<dd><%= "%0.2f" % data[:unscheduled_hours] %></dd>
</dl>
<% end %>
</th>
<% # Print the total workload for this group for each day %>
<% user = GroupUserDummy.new(group: group) %>
<%= render :partial => 'total_workload', :locals => {:totalWorkload => data[:total], :user => user } %>
</tr>
</tbody>
<% if data[:invisible].presence %>
<tbody class="invisible-issues-summary <%= css_group_class(group&.id) %>">
<tr>
<th class="invisible-workload-description" scope="row"><%= l(:workload_show_invisible_issues) %> </th>
<%= render :partial => 'summarized_workload_for_invisible_issues', :locals => {:assignee => group, :summarizedWorkload => data[:invisible]} %>
</tr>
</tbody>
<% end %>
<% # Iterate over all assignees for the group %>
<% assignees = data.keys.select{|key| key.kind_of?(User) || key.kind_of?(Group) || key.kind_of?(GroupUserDummy) } %>
<% assignees.each do |assignee| %>
<%= render :partial => 'workload_for_user_in_group', :locals => {:group => group, :user => assignee, :data => data[assignee]} %>
<% end %>

View File

@@ -0,0 +1,47 @@
<%
# Renders the workload data for one single issue.
# Parameters:
# * user: The user to render the data for.
# * issue: The issue to render the data for.
# * data: The data to render. A hash with days as keys.
# * index: Index of the issue for this user.
%>
<%
klass = (index % 2 == 0) ? 'even' : 'odd'
klass += ' overdue' if !issue.due_date.nil? && (issue.due_date < @today)
%>
<tr class="issue-workloads <%= css_group_user_project_class(group&.id, user&.id, issue.project&.id) %> <%= klass %>">
<th class="issue-description" scope="row">
<div class="tooltip"><%= link_to_issue(issue) %>
<span class="tip"><%= render_issue_tooltip(issue) %></span>
</div>
</th>
<%
data.keys.compact.sort.each do |day|
dataForDay = data[day]
hours = dataForDay[:hours]
lowload = dataForDay[:lowload]
normalload = dataForDay[:normalload]
highload = dataForDay[:highload]
klass = 'hours'
klass += ' active' if dataForDay[:active]
klass += ' not-active' if !dataForDay[:active]
klass += ' holiday' if dataForDay[:holiday]
klass += ' workingday' if !dataForDay[:holiday]
klass += ' not-estimated' if dataForDay[:noEstimate]
klass += ' estimated' if !dataForDay[:noEstimate]
klass += ' today' if @today === day
klass += ' ' + load_class_for_hours(hours, lowload, normalload, highload)
hoursString = (hours.abs < 0.01) ? '' : sprintf("%.1f", hours)
%>
<td class="<%= klass %>">
<span>
<%= hoursString %>
</span>
</td>
<% end %>
</tr>

View File

@@ -0,0 +1,38 @@
<%#
# Renders the workload data for one single project for one single user.
# Parameters:
# * user: The user to render the data for.
# * project: The project to render
# * data: The data to render. A hash with issues as keys.
%>
<tbody class="project-total-workload <%= css_group_user_class(group&.id, user&.id) %>">
<tr>
<th class="project-description <%= css_group_user_project_class(group&.id, user&.id, project&.id) %>" scope="row" title="<%= l(:workload_trigger_tooltip) %>">
<%= render :partial => 'trigger', :locals => {:trigger_for => css_group_user_project_class(group&.id, user&.id, project&.id) } %>
<%= project.to_s %>
<% if data[:overdue_number]&.positive? || data[:unscheduled_number]&.positive? %>
<dl class="additional-project-info">
<dt><%= l(:workload_overdue_issues_num) %></dt>
<dd><%= data[:overdue_number] %></dd>
<dt><%= l(:workload_overdue_issues_hours) %></dt>
<dd><%= "%0.2f" % data[:overdue_hours] %></dd>
<dt class='mt-5'><%= l(:workload_unscheduled_issues_num) %></dt>
<dd class='mt-5'><%= data[:unscheduled_number] %></dd>
<dt><%= l(:workload_unscheduled_issues_hours) %></dt>
<dd><%= "%0.2f" % data[:unscheduled_hours] %></dd>
</dl>
<% end %>
</th>
<% # Print the total workload for this project for each day %>
<%= render :partial => 'total_workload', :locals => { :totalWorkload => data[:total], :user => user } %>
</tr>
</tbody>
<tbody class="issue-workloads <%= css_group_user_project_class(group&.id, user&.id, project&.id) %>">
<% # Iterate over all issues for the project %>
<% issuesForUser = data.keys.select{|x| x.kind_of?(Issue)} %>
<% issuesForUser.each_with_index do |issue, index| %>
<%= render :partial => 'workload_for_issue', :locals => { :group => group, :user => user, :issue => issue, :data => data[issue], :index => index } %>
<% end %>
</tbody>

View File

@@ -0,0 +1,43 @@
<%
# Renders the workload data for one single user.
# Parameters:
# * user: The user to render the data for.
# * data: The data to render. A hash with issues as keys.
%>
<tbody class="user-total-workload" id="user-total-workload-<%= user.id %>">
<tr>
<th class="user-description <%= css_user_class(user&.id) %>" scope="row" title="<%= l(:workload_trigger_tooltip) %>">
<%= render :partial => 'trigger', :locals => { :trigger_for => css_user_class(user&.id) } %>
<%= "#{user.firstname} #{user.lastname}" %>
<% if data[:overdue_number]&.positive? || data[:unscheduled_number]&.positive? %>
<dl class="additional-user-info">
<dt><%= l(:workload_overdue_issues_num) %></dt>
<dd><%= data[:overdue_number] %></dd>
<dt><%= l(:workload_overdue_issues_hours) %></dt>
<dd><%= "%0.2f" % data[:overdue_hours] %></dd>
<dt class='mt-5'><%= l(:workload_unscheduled_issues_num) %></dt>
<dd class='mt-5'><%= data[:unscheduled_number] %></dd>
<dt><%= l(:workload_unscheduled_issues_hours) %></dt>
<dd><%= "%0.2f" % data[:unscheduled_hours] %></dd>
</dl>
<% end %>
</th>
<% # Print the total workload for this user for each day %>
<%= render :partial => 'total_workload', :locals => { :totalWorkload => data[:total], :user => user } %>
</tr>
</tbody>
<% if data[:invisible].presence %>
<tbody class="invisible-issues-summary <%= css_user_class(user&.id) %>">
<tr>
<th class="invisible-workload-description" scope="row"><%= l(:workload_show_invisible_issues) %> </th>
<%= render :partial => 'summarized_workload_for_invisible_issues', :locals => { :assignee => user, :summarizedWorkload => data[:invisible] } %>
</tr>
</tbody>
<% end %>
<% # Iterate over all projects for the user %>
<% projects = data.keys.select{|x| x.kind_of?(Project)} %>
<% projects.each do |project| %>
<%= render :partial => 'workload_for_project', :locals => { :group => nil, :user => user, :project => project, :data => data[project] } %>
<% end %>

View File

@@ -0,0 +1,45 @@
<%
# Renders the workload data for one single user.
# Parameters:
# * user: The user to render the data for.
# * data: The data to render. A hash with issues as keys.
%>
<tbody class="user-total-workload-in-<%= css_group_class(group&.id) %> user-total-workload <%= css_user_class(user&.id) %>">
<tr>
<th class="user-description <%= css_group_class(group&.id) %> <%= css_user_class(user&.id) %>" scope="row" title="<%= l(:workload_trigger_tooltip) %>">
<%= render :partial => 'trigger', :locals => { :trigger_for => css_group_user_class(group&.id, user&.id) } %>
<%= "#{user.firstname} #{user.lastname}" %>
<% if data[:overdue_number]&.positive? || data[:unscheduled_number]&.positive? %>
<dl class="additional-user-info">
<dt><%= l(:workload_overdue_issues_num) %></dt>
<dd><%= data[:overdue_number] %></dd>
<dt><%= l(:workload_overdue_issues_hours) %></dt>
<dd><%= "%0.2f" % data[:overdue_hours] %></dd>
<dt class='mt-5'><%= l(:workload_unscheduled_issues_num) %></dt>
<dd class='mt-5'><%= data[:unscheduled_number] %></dd>
<dt><%= l(:workload_unscheduled_issues_hours) %></dt>
<dd><%= "%0.2f" % data[:unscheduled_hours] %></dd>
</dl>
<% end %>
</th>
<% # Print the total workload for this user for each day %>
<% if data[:total].presence %>
<%= render :partial => 'total_workload', :locals => {:totalWorkload => data[:total], :user => user} %>
<% end %>
</tr>
</tbody>
<% if data[:invisible].presence %>
<tbody class="invisible-issues-summary <%= css_group_class(group&.id) %>">
<tr>
<th class="invisible-workload-description" scope="row"><%= l(:workload_show_invisible_issues) %> </th>
<%= render :partial => 'summarized_workload_for_invisible_issues', :locals => {:assignee => user, :summarizedWorkload => data[:invisible]} %>
</tr>
</tbody>
<% end %>
<% # Iterate over all projects for the user %>
<% projects = data.keys.select{|x| x.kind_of?(Project)} %>
<% projects.each do |project| %>
<%= render :partial => 'workload_for_project', :locals => {:user => user, :group => group, :project => project, :data => data[project]} %>
<% end %>

View File

@@ -0,0 +1,51 @@
<% html_title(l(:workload_site_title)) %>
<div class="contextual">
<%= render_action_links %>
</div>
<h2><%= l(:workload_show_label) %></h2>
<% if @date_check %>
<%= error_messages_for 'query' %>
<div class="wrapper">
<table class="data">
<thead>
<tr class="workload_hdr">
<td class="workload_hdr" rowspan="4">&nbsp;<!-- empty space --></td>
<%= render :partial => 'month_names_header' %>
</tr>
<tr class="workload_hdr" >
<%= render :partial => 'num_of_week_header' %>
</tr>
<tr class="workload_hdr">
<%= render :partial => 'day_of_month_header' %>
</tr>
<tr class="workload_hdr">
<%= render :partial => 'day_of_week_header' %>
</tr>
</thead>
<% if groups? @groups %>
<% @group_workload&.by_group&.each do |group, data| %>
<%= render :partial => 'workload_for_group',
:locals => { :group => group,
:data => data }
%>
<% end %>
<% else %>
<% @workload_data&.keys&.each do |user| %>
<%= render :partial => 'workload_for_user',
:locals => { :group => nil,
:user => user,
:data => @workload_data[user] }
%>
<% end %>
<% end %>
</table>
</div>
<%= render partial: 'export', locals: { groups: @groups } %>
<% end %>
<% content_for :sidebar do %>
<%= render partial: 'filters' %>
<% end %>

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
assets/images/logo.xcf Normal file

Binary file not shown.

View File

@@ -0,0 +1,64 @@
/**
Toggle along the hierarchie tree.
When opening, open level by level. When closing, close the item with all
lower levels at once.
*/
$(document).ready(function() {
$('.trigger').click(function() {
var OPENED = '&#x25bc;'
var CLOSED = '&#x25b6;'
$(this).toggleClass('closed opened');
identifier = $(this).attr('data-for');
identifierClasses = identifier.trim().replace(/\s/g, ".");
// topDownHierarchieChain shows current hierarchie level on the left and the css
// class of the next hierarchie level on the right hand side.
topDownHierarchieChain = new Map([
["group-description " + identifier, ".user-total-workload-in-" + identifierClasses],
["user-description " + identifier, ".project-total-workload." + identifierClasses],
["project-description " + identifier, ".issue-workloads." + identifierClasses]
]);
// bottomUpHierarchies shows current hierarchie level on the left and all
// lower hierarchie levels on the right hand side.
bottomUpHierarchieChain = new Map([
["group-description " + identifier, [".issue-workloads." + identifierClasses,
".project-total-workload." + identifierClasses,
".user-total-workload-in-" + identifierClasses]],
["user-description " + identifier, [".issue-workloads." + identifierClasses,
".project-total-workload." + identifierClasses]],
["project-description " + identifier, [".issue-workloads." + identifierClasses]]
]);
currentHierarchieLevel = $(this).parent().attr('class');
if ($(this).hasClass('opened')) {
$(this).show();
// Shows additional info
$(this).siblings().show();
// Reveals the next hierarchie level
nextHierarchieLevelClass = topDownHierarchieChain.get(currentHierarchieLevel);
$(nextHierarchieLevelClass).each(function(){
$(this).show(); // but keep its 'children' closed if any
$(this).siblings('.invisible-issues-summary.' + identifierClasses).show();
});
$(this).html(OPENED);
}
else {
lowerHierarchieLevelClasses = bottomUpHierarchieChain.get(currentHierarchieLevel);
// Collapses all lower levels of the currentHierarchieLevel at once
// as defined in bottomUpHierarchieChain.
lowerHierarchieLevelClasses.forEach(function(css){
$(css).hide();
$(css).siblings('.invisible-issues-summary.' + identifierClasses).hide();
currentHierarchieLevel = $(css).find('span.trigger.opened');
currentHierarchieLevel.html(CLOSED);
currentHierarchieLevel.siblings('dl').hide();
})
$(this).siblings().hide();
$(this).html(CLOSED);
}
});
});

View File

@@ -0,0 +1,336 @@
/*******************************************************************************
* Color scheme.
******************************************************************************/
:root {
--lightgray: #eee;
--mediumgray: #c0c0c0;
--anthracite: #333;
}
/*******************************************************************************
* Normalisation.
******************************************************************************/
.controller-workloads table th, table td {
padding: 0;
}
.controller-workloads table dt {
margin-top: 0;
}
.controller-workloads table dd {
margin-bottom: 0;
}
/*******************************************************************************
* Styles for the filter form.
******************************************************************************/
.controller-workloads .filters > div,
.controller-workloads .filters .apply {
margin-top: 8px;
}
.controller-workloads .filters .apply {
display: inline-block;
}
legend {
color: var(--anthracite);
}
.controller-workloads .wrapper {
margin-top: 30px;
}
.controller-workloads .users select,
.controller-workloads .groups select {
width: 300px;
height: 150px;
}
/*******************************************************************************
* Styles for the header of workload table.
******************************************************************************/
.controller-workloads .data .workload_hdr {
background-color: var(--lightgray);
border: 1px solid var(--mediumgray);
}
/* Column width if no data is displayed */
.controller-workloads .data .day-of-month,
.controller-workloads .data .day-of-week {
min-width: 25px;
font-size: 0.8em;
}
/* Month names */
.controller-workloads .data .month-name {
border-left: 1px solid var(--mediumgray);
border-top: 1px solid var(--mediumgray);
}
.controller-workloads .data .month-name:last-child {
border-right: 1px solid var(--mediumgray);
}
/* Num of week */
.controller-workloads .data .num-of-week.first-day-of-week {
border-left: 1px solid var(--mediumgray);
}
.controller-workloads .data .num-of-week.last-day-of-week {
border-right: 1px solid var(--mediumgray);
}
/* Day of month */
.controller-workloads .data .day-of-month.firstDayOfMonth,
.controller-workloads .data .day-of-month:first-child {
border-left: 1px solid var(--mediumgray);
}
.controller-workloads .data .day-of-month:last-child {
border-right: 1px solid var(--mediumgray);
}
/* Day of week */
.controller-workloads .data .day-of-week.firstDayOfWeek,
.controller-workloads .data .day-of-week:first-child {
border-left: 1px solid var(--mediumgray);
}
.controller-workloads .data .day-of-week:last-child {
border-right: 1px solid var(--mediumgray);
}
/*******************************************************************************
* Styles for the workload table.
******************************************************************************/
.controller-workloads .wrapper {
overflow-x: auto;
}
.controller-workloads .data {
table-layout: fixed;
border-spacing: 0 3px;
border-collapse: collapse;
margin-bottom: 10px;
}
.controller-workloads .data th {
font-weight: initial;
}
/*------------------------------------------------------------------------------
* Styles for the stuff that may be shown or hidden.
-----------------------------------------------------------------------------*/
.controller-workloads .data .additional-group-info,
.controller-workloads .data .additional-user-info,
.controller-workloads .data .additional-project-info,
.controller-workloads .data .invisible-issues-summary,
.controller-workloads .data tbody[class^="user-total-workload-in-group-"],
.controller-workloads .data .project-total-workload,
.controller-workloads .data .issue-workloads {
display: none;
}
.controller-workloads .data .trigger {
display: inline-block;
cursor: default;
text-align: center;
font-size: 0.9em;
margin-right: 4px;
}
.controller-workloads table dt.mt-5,
.controller-workloads table dd.mt-5 {
margin-top: 5px;
}
/*------------------------------------------------------------------------------
* Table heads on the left side.
-----------------------------------------------------------------------------*/
.controller-workloads .data .group-description,
.controller-workloads .data .user-description,
.controller-workloads .data .project-description,
.controller-workloads .data .issue-description,
.controller-workloads .data .invisible-workload-description {
text-align: left;
border-left: 1px solid var(--mediumgray);
border-right: 1px solid var(--mediumgray);
width: 300px;
min-width: 300px;
max-width: 300px;
}
.controller-workloads .data .group-description {
padding-left: 5px;
}
.controller-workloads .data .user-description {
padding-left: 10px;
}
.controller-workloads .data .project-description {
padding-left: 15px;
}
.controller-workloads .data .issue-description,
.controller-workloads .data .invisible-workload-description {
padding-left: 20px;
}
.controller-workloads .data .invisible-workload-description {
font-size: 1em;
}
.controller-workloads .data .additional-group-info,
.controller-workloads .data .additional-user-info,
.controller-workloads .data .additional-project-info {
font-size: 0.9em;
font-weight: normal;
margin-left: 30px;
}
.controller-workloads .data .additional-group-info dt,
.controller-workloads .data .additional-user-info dt,
.controller-workloads .data .additional-project-info dt {
float: left;
clear: left;
width: 210px;
}
.controller-workloads .data .additional-group-info dd,
.controller-workloads .data .additional-user-info dd,
.controller-workloads .data .additional-project-info dd {
margin-left: 210px;
}
/*------------------------------------------------------------------------------
* Real table data
-----------------------------------------------------------------------------*/
.controller-workloads .tooltip span.tip {
top: 20px;
}
.controller-workloads td.hours {
font-weight: normal;
}
.controller-workloads .data tr td:last-child {
border-right: 1px solid var(--mediumgray);
}
.controller-workloads .data tr:last-child td,
.controller-workloads .data tr:last-child th {
border-bottom: 1px solid var(--mediumgray);
}
.controller-workloads .data .user-total-workload {
background-color: #F6F7F8;
}
.controller-workloads .data .issue-workloads.odd {
background-color: #F6F7F8;
}
.controller-workloads .data .issue-workloads.odd.overdue {
background-color: #ecb4b4;
}
.controller-workloads .data .issue-workloads.even {
background-color: white;
}
.controller-workloads .data .issue-workloads.even.overdue {
background-color: #f8d0d0;
}
.controller-workloads .data .issue-workloads:hover,
.controller-workloads .data .group-total-workload:hover,
.controller-workloads .data .user-total-workload:hover,
.controller-workloads .data .project-total-workload:hover{
background-color: #D7D7D7!important;
}
.controller-workloads .data td {
padding: 0;
font-size: .8em;
line-height: 1;
text-align: center;
width: 25px;
min-width: 25px;
}
.controller-workloads .data .today {
border-right: 2px dashed red;
padding-left: 2px;
margin-left: 2px;
}
/* Styling of the spans in the table */
.controller-workloads .data .hours span {
display: block;
padding-top: .6em;
padding-bottom: .3em;
height: 1em;
color: white;
}
.controller-workloads .data .hours.none span {
color: var(--anthracite);
}
.controller-workloads .data .none span {
background: transparent;
}
.controller-workloads .data .active span {
background-color: rgba(200, 200, 200, 0.5);
}
.controller-workloads .data .low span {
background: green;
}
.controller-workloads .data .normal span {
background: yellow;
color: var(--anthracite);
}
.controller-workloads .data .high span {
background: red;
}
.controller-workloads .data .holiday {
background-color: var(--lightgray);
border-left: 1px solid var(--mediumgray);
border-right: 1px solid var(--mediumgray);
}
.controller-workloads .data .holiday.today {
background-color: var(--lightgray);
border-left: 2px solid red;
border-right: 1px solid var(--mediumgray);
}
.controller-workloads .data .holiday span {
display: none;
}
.controller-workloads .data .active.holiday span {
background-color: transparent;
}
/*******************************************************************************
* Styles for national holiday page.
******************************************************************************/
#year-nav {
padding-top: 2rem;
}

106
config/locales/de.yml Executable file
View File

@@ -0,0 +1,106 @@
# German strings go here
de:
permission_edit_national_holiday: "Feiertage bearbeiten"
permission_edit_user_vacations: "Eigene Urlaube bearbeiten"
permission_edit_user_data: "Eigene Belastungsgrenzen bearbeiten"
permission_view_all_workloads: "Alle Workloads anzeigen"
permission_view_own_workloads: "Eigene Workloads anzeigen"
permission_view_own_group_workloads: "Workloads eigener Gruppen anzeigen"
workload_title: "Workload"
workload_site_title: "Workload"
workload_show_label: "Workload"
workload_show_filters: "Filter"
workload_show_range: "Anzeigebereich"
workload_show_rangefrom: "von"
workload_show_rangeto: "bis"
workload_show_today: "Als \"Heute\" annehmen:"
workload_show_filter_user_legend: "Nutzer- oder Gruppenfilter"
workload_show_filter_user: "Wähle Nutzer"
workload_show_filter_group: "Wähle Gruppe(n)"
workload_show_issues: "Zeige Tickets:"
workload_show_invisible_issues: "Für Sie unsichtbare Tickets:"
workload_show_issue_estimated_hours: "Geschätzer Aufwand"
workload_show_issue_spent_time: "Aufgewendete Zeit"
workload_show_issue_status: "Status"
workload_show_issue_percent_done: "% erledigt"
workload_show_issue_percent_estimated: "% geschätzt"
workload_show_dcr: "Resourceneinsatz Differenz:"
workload_show_date: "Timing"
workload_show_a_hours: "Effizienz"
workload_show_user_total_hours_remaining: "Verbleibend"
workload_show_issue_priority: "Priorität"
workload_show_issue_date: "Start/Ende"
workload_show_legend: "Legende"
workload_show_legend_title: "Timing"
workload_show_legend_perfect: "Perfekt"
workload_show_legend_normal: "Pünktlich"
workload_show_legend_retard: "Verzögerung"
workload_show_legend_retard2: "Verzögerung"
workload_show_legend_no_time: "Kein Timing"
workload_show_legend_father: "Übergeordnetes Ticket (wird nicht mit aufsummiert)"
workload_show_legend_time_spent: "Aufgewendete Zeit"
workload_show_legend_past: "In der Vergangenheit"
workload_show_legend_out: "Budget verbraucht"
workload_trigger_tooltip: "Pfeil anklicken für mehr Details"
workload_overdue_issues_num: "Anzahl überfälliger Tickets:"
workload_overdue_issues_hours: "Stunden aus überfälligen Tickets:"
workload_settings_general_workdays: "Wöchentliche Arbeitstage"
workload_settings_general_workdays_explanation: "Diese Tage werden bei der Berechnung der Arbeitsbelastung als Arbeitstage angenommen:"
workload_settings_general_workdays_monday: "Montag"
workload_settings_general_workdays_tuesday: "Dienstag"
workload_settings_general_workdays_wednesday: "Mittwoch"
workload_settings_general_workdays_thursday: "Donnerstag"
workload_settings_general_workdays_friday: "Freitag"
workload_settings_general_workdays_saturday: "Samstag"
workload_settings_general_workdays_sunday: "Sonntag"
workload_settings_hours: "Belastungsgrenzen"
workload_settings_leastdailyworkload: "Kleinste Arbeitsbelastung pro Tag, deren Last als ..."
workload_settings_hours_low_min: "... niedrig angesehen wird:"
workload_settings_hours_normal_min: "... normal angesehen wird:"
workload_settings_hours_high_min: "... überlastet angesehen wird:"
workload_settings_main_group: "Hauptgruppe"
workload_settings_holiday_setup: "Feiertage bearbeiten"
workload_settings_holiday_new: "Feiertag anlegen"
workload_user_vacation_site_title: "Urlaubsübersicht"
workload_user_vacation: "Urlaub"
workload_user_vacation_menu: "Meine Urlaube"
workload_user_vacation_new: "Neuen Urlaub anlegen"
workload_user_vacation_edit: "Urlaub bearbeiten"
workload_user_vacation_type: "Typ"
workload_user_vacation_comments: "Kommentar"
workload_user_vacation_date_end: "Ende"
notice_user_vacation_saved: "Urlaub wurde erfolgreich gespeichert"
notice_user_vacation_deleted: "Urlaub wurde erfolgreich gelöscht"
workload_user_data_site_title: "Benutzer Workload Information"
workload_user_data_title: "Meine Einstellungen"
workload_holiday_title: "Feiertage"
workload_holiday_reason: "Anlass"
notice_settings_updated: Einstellungen erfolgreich aktualisiert.
notice_holiday_updated: "Feiertag wurde erfolgreich aktualisiert"
notice_holiday_saved: "Feiertag wurde erfolgreich gespeichert"
notice_holiday_deleted: "Feiertag wurde erfolgreich gelöscht"
label_assigned_to_group: Zugewiesen an %{value}
description_selected_groups: Ausgewählte Gruppen
description_all_groups: Alle Gruppen
description_selected_users: Ausgewählte Nutzer
description_all_users: Alle Nutzer
field_main_group: Hauptgruppe
field_number_of_overdue_issues: Anzahl überfälliger Tickets
field_number_of_overdue_hours: Stunden aus überfälligen Tickets
field_number_of_unscheduled_issues: Anzahl nicht geplanter Tickets
field_number_of_unscheduled_hours: Stunden aus nicht geplanten Tickets
label_planned: geplant
label_available: verfügbar
label_aggregation: Aggregation
field_end: Ende
field_start: Beginn
field_date_to: Ende
field_date_from: Beginn
field_reason: Anlass
workload_unscheduled_issues_num: 'Anzahl ungeplanter Tickets:'
workload_unscheduled_issues_hours: 'Stunden aus ungeplanten Tickets:'
error_date_setting: 'Überprüfen Sie Ihre Eingabe! Das Enddatum (bis) liegt vor dem Startdatum (von).'
label_workload_calculation: Workloadberechnung
label_include_parent_tasks: Hauptaufgaben einbeziehen
info_include_parent_tasks: Werden Hauptaufgaben nicht einbezogen, wird lediglich der geschätzte Aufwand von Unteraufgaben berücksichtigt!
warning_include_parent_tasks: Wenn der Workload von Haupt- und Unteraufgaben unabhängig voneinander berechnet werden soll, muss in Administration » Tickets » Eigenschaften übergeordneter Tickets das Feld %-erledigt angepasst werden.

106
config/locales/en.yml Executable file
View File

@@ -0,0 +1,106 @@
# English strings go here
en:
permission_edit_national_holiday: "Edit national Holidays"
permission_edit_user_vacations: "Edit own vacations"
permission_edit_user_data: "Edit own workload thresholds"
permission_view_all_workloads: "View all workloads"
permission_view_own_workloads: "View own workloads"
permission_view_own_group_workloads: "View own group workloads"
workload_title: "Workload"
workload_site_title: "Workload"
workload_show_label: "Workload"
workload_show_filters: "Filters"
workload_show_range: "Range"
workload_show_rangefrom: "from"
workload_show_rangeto: "until"
workload_show_today: "Use as \"today\":"
workload_show_filter_user_legend: "Filter User or Groups"
workload_show_filter_user: "Select User(s)"
workload_show_filter_group: "Select Group(s)"
workload_show_issues: "Show issues:"
workload_show_invisible_issues: "Issues invisible to you:"
workload_show_issue_estimated_hours: "Estimated"
workload_show_issue_spent_time: "Spent time"
workload_show_issue_status: "Status"
workload_show_issue_percent_done: "% done"
workload_show_issue_percent_estimated: "% estim."
workload_show_dcr: "Resource consumption diff:"
workload_show_date: "Timing"
workload_show_a_hours: "Efficiency"
workload_show_user_total_hours_remaining: "Remaining"
workload_show_issue_priority: "Priority"
workload_show_issue_date: "Start/End"
workload_show_legend: "Legend"
workload_show_legend_title: "Timing"
workload_show_legend_perfect: "Perfect"
workload_show_legend_normal: "On time"
workload_show_legend_retard: "Delay"
workload_show_legend_retard2: "Delay"
workload_show_legend_no_time: "No timing"
workload_show_legend_father: "Parent task (does not add up)"
workload_show_legend_time_spent: "Time spent"
workload_show_legend_past: "Past"
workload_show_legend_out: "Out of budget"
workload_trigger_tooltip: "Click the triangle for more details"
workload_overdue_issues_num: "Number of overdue issues:"
workload_overdue_issues_hours: "Hours from overdue issues:"
workload_settings_general_workdays: "Weekly working days"
workload_settings_general_workdays_explanation: "The following days will be considered as working days when computing the workload:"
workload_settings_general_workdays_monday: "Monday:"
workload_settings_general_workdays_tuesday: "Tuesday:"
workload_settings_general_workdays_wednesday: "Wednesday:"
workload_settings_general_workdays_thursday: "Thursday:"
workload_settings_general_workdays_friday: "Friday:"
workload_settings_general_workdays_saturday: "Saturday:"
workload_settings_general_workdays_sunday: "Sunday:"
workload_settings_hours: "Workload thresholds"
workload_settings_leastdailyworkload: "Smallest workload that is considered to be ..."
workload_settings_hours_low_min: "... low:"
workload_settings_hours_normal_min: "... normal:"
workload_settings_hours_high_min: "... overloaded:"
workload_settings_main_group: "Main group"
workload_settings_holiday_setup: "Edit holidays"
workload_settings_holiday_new: "Create holiday"
workload_user_vacation_site_title: "Vacation Overview"
workload_user_vacation: "Vacation"
workload_user_vacation_menu: "My Vacations"
workload_user_vacation_new: "Create new Vacation"
workload_user_vacation_edit: "Edit Vacation"
workload_user_vacation_type: "Type"
workload_user_vacation_comments: "Coment"
workload_user_vacation_date_end: "End"
notice_user_vacation_saved: "Vacation successfully saved"
notice_user_vacation_deleted: "Vacation successfully deleted"
workload_user_data_site_title: "User Workload Information"
workload_user_data_title: "My Setup"
workload_holiday_title: "Holidays"
workload_holiday_reason: "Reason"
notice_settings_updated: Settings sucessfully updated
notice_holiday_updated: Holiday was successfully updated
notice_holiday_saved: Holiday was successfully saved
notice_holiday_deleted: Holiday was successfully deleted
label_assigned_to_group: Assigned to %{value}
description_selected_groups: Selected groups
description_all_groups: All groups
description_selected_users: Selected users
description_all_users: All users
field_main_group: Main group
field_number_of_overdue_issues: Number of overdue issues
field_number_of_overdue_hours: Hours of overdue issues
field_number_of_unscheduled_issues: Number of unscheduled issues
field_number_of_unscheduled_hours: Hours of unscheduled issues
label_planned: planned
label_available: available
label_aggregation: Aggregation
field_end: End
field_start: Begin
field_date_to: End
field_date_from: Begin
field_reason: Reason
workload_unscheduled_issues_num: 'Number of unscheduled issues:'
workload_unscheduled_issues_hours: 'Hours of unscheduled issues:'
error_date_setting: 'Please check your data! The end date is before the start date.'
label_workload_calculation: Workload calculation
label_include_parent_tasks: Include parent tasks
info_include_parent_tasks: If parent tasks would not be considered then only estimated times of child issues will be included in the workload calculation!
warning_include_parent_tasks: If the workload of parent and child issues should be considered independently then you need to adjust the setting in Administration » Settings » Issue tracking » Parent tasks attributes accordingly.

39
config/locales/es.yml Normal file
View File

@@ -0,0 +1,39 @@
# Spanish strings go here
es:
permission_edit_national_holiday: "Edit national Holidays"
permission_edit_user_vacations: "Edit own vacations"
permission_edit_user_data: "Edit own workload thresholds"
permission_view_all_workloads: "View all workloads"
permission_view_own_workloads: "View own workloads"
permission_view_own_group_workloads: "View own group workloads"
workload_title: "Workload"
workload_site_title: "Workload"
workload_show_label: "Workload"
workload_show_filters: "Filters"
workload_show_range: "Rango"
workload_show_today: "Fecha de c&aacute;lculo"
workload_show_filter_user: "Usuarios"
workload_show_issues: "Mostrar Tareas"
workload_show_invisible_issues: "Issues invisible to you:"
workload_show_issue_estimated_hours: "Estimated"
workload_show_issue_spent_time: "Spent time"
workload_show_issue_status: "Status"
workload_show_issue_percent_done: "% done"
workload_show_issue_percent_estimated: "% estim."
workload_show_dcr: "Resource consumption diff:"
workload_show_date: "Timing"
workload_show_a_hours: "Efficiency"
workload_show_user_total_hours_remaining: "Remaining"
workload_show_issue_priority: "Priority"
workload_show_issue_date: "Start/End"
workload_show_legend: "Legend"
workload_show_legend_title: "Timing"
workload_show_legend_perfect: "Perfect"
workload_show_legend_normal: "On time"
workload_show_legend_retard: "Delay"
workload_show_legend_retard2: "Delay"
workload_show_legend_no_time: "No timing"
workload_show_legend_father: "Parent task (does not add up)"
workload_show_legend_time_spent: "Time spent"
workload_show_legend_past: "Past"
workload_show_legend_out: "Out of budget"

39
config/locales/fr.yml Normal file
View File

@@ -0,0 +1,39 @@
# French strings go here
fr:
permission_edit_national_holiday: "Edit national Holidays"
permission_edit_user_vacations: "Edit own vacations"
permission_edit_user_data: "Edit own workload thresholds"
permission_view_all_workloads: "View all workloads"
permission_view_own_workloads: "View own workloads"
permission_view_own_group_workloads: "View own group workloads"
workload_title: "Charge de Travail"
workload_site_title: "Charge"
workload_show_label: "Charge"
workload_show_filters: "Filters"
workload_show_range: "Écart"
workload_show_today: "Aujourd'hui"
workload_show_filter_user: "Utilisateurs"
workload_show_issues: "Voir les tickets"
workload_show_invisible_issues: "Issues invisible to you:"
workload_show_issue_estimated_hours: "Estimé"
workload_show_issue_spent_time: "Temps consumé"
workload_show_issue_status: "État"
workload_show_issue_percent_done: "% réalisé"
workload_show_issue_percent_estimated: "% estim."
workload_show_dcr: "Différence des ressources consommées"
workload_show_date: "Timing"
workload_show_a_hours: "Efficaticté"
workload_show_user_total_hours_remaining: "Restant"
workload_show_issue_priority: "Priorité"
workload_show_issue_date: "DÉbut / Fin"
workload_show_legend: "Légende"
workload_show_legend_title: "Timing"
workload_show_legend_perfect: "Parfait"
workload_show_legend_normal: "Dans les temps"
workload_show_legend_retard: "Retard"
workload_show_legend_retard2: "Retartd important"
workload_show_legend_no_time: "Non planifié"
workload_show_legend_father: "tâche parent (non renseignée)"
workload_show_legend_time_spent: "Temps passé"
workload_show_legend_past: "Passé"
workload_show_legend_out: "Hors budget"

60
config/locales/it.yml Normal file
View File

@@ -0,0 +1,60 @@
# Italian strings go here
it:
permission_edit_national_holiday: "Edit national Holidays"
permission_edit_user_vacations: "Edit own vacations"
permission_edit_user_data: "Edit own workload thresholds"
permission_view_all_workloads: "View all workloads"
permission_view_own_workloads: "View own workloads"
permission_view_own_group_workloads: "View own group workloads"
workload_title: "Workload"
workload_site_title: "Workload"
workload_show_label: "Workload"
workload_show_filters: "Filtri"
workload_show_range: "Periodo:"
workload_show_rangefrom: "da:"
workload_show_rangeto: "a:"
workload_show_today: "Usa come \"OGGI\":"
workload_show_filter_user: "Utenti:"
workload_show_issues: "Mostra segnalazioni:"
workload_show_invisible_issues: "Segnalazioni non visibili a te:"
workload_show_issue_estimated_hours: "Stimato"
workload_show_issue_spent_time: "Tempo trascorso"
workload_show_issue_status: "Stato"
workload_show_issue_percent_done: "% eseguito"
workload_show_issue_percent_estimated: "% stimato."
workload_show_dcr: "Differenza nell'impiego delle risorse:"
workload_show_date: "Tempo"
workload_show_a_hours: "Efficienza"
workload_show_user_total_hours_remaining: "Rimanente"
workload_show_issue_priority: "Priorità"
workload_show_issue_date: "Start/End"
workload_show_legend: "Legenda"
workload_show_legend_title: "Tempo"
workload_show_legend_perfect: "Perfetto"
workload_show_legend_normal: "In tempo"
workload_show_legend_retard: "Ritardo"
workload_show_legend_retard2: "Ritardo2"
workload_show_legend_no_time: "Senza tempo"
workload_show_legend_father: "Parent task (does not add up)"
workload_show_legend_time_spent: "Tempo impiegato"
workload_show_legend_past: "Passato"
workload_show_legend_out: "Fuori budget"
workload_trigger_tooltip: "Click sul triangolo per maggiori dettagli"
workload_overdue_issues_num: "Numero di segnalazioni scadute:"
workload_overdue_issues_hours: "Ore da segnalazioni scadute:"
workload_settings_general_workdays: "Giorni lavorativi settimanali"
workload_settings_general_workdays_explanation: "I seguenti giorni settimanali saranno considerati come lavorativi:"
workload_settings_general_workdays_monday: "Lunedì:"
workload_settings_general_workdays_tuesday: "Martedì:"
workload_settings_general_workdays_wednesday: "Mercoledì:"
workload_settings_general_workdays_thursday: "Giovedì:"
workload_settings_general_workdays_friday: "Venerdì:"
workload_settings_general_workdays_saturday: "Sabato:"
workload_settings_general_workdays_sunday: "Domenica:"
workload_settings_hours: "Soglie di carico"
workload_settings_leastdailyworkload: "La soglia minima che dev'essere considerata di carico ..."
workload_settings_hours_low_min: "... basso:"
workload_settings_hours_normal_min: "... normale:"
workload_settings_hours_high_min: "... sovraccarico:"

7
config/routes.rb Normal file
View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
resources :workloads, only: %w[index]
resources :wl_user_datas, only: %w[edit update]
resources :wl_national_holiday
resources :wl_user_vacations

View File

@@ -0,0 +1,14 @@
# frozen_string_literal: true
class CreateWlUserVacations < ActiveRecord::Migration[5.2]
def change
create_table :wl_user_vacations do |t|
t.belongs_to :user, index: true, null: false
t.column :date_from, :date, null: false
t.column :date_to, :date, null: false
t.column :comments, :string, limit: 255
t.column :vacation_type, :string, limit: 255
t.column :ref_id, :integer # optional: for sync purpose with external system
end
end
end

View File

@@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreateWlUserData < ActiveRecord::Migration[5.2]
def change
create_table :wl_user_datas do |t|
t.belongs_to :user, index: true, null: false
t.float :threshold_lowload_min, null: false
t.float :threshold_normalload_min, null: false
t.float :threshold_highload_min, null: false
end
end
end

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
class CreateWlNationalHolidays < ActiveRecord::Migration[5.2]
def change
create_table :wl_national_holidays do |t|
t.date :start, null: false
t.date :end, null: false
t.string :reason, null: false
end
end
end

View File

@@ -0,0 +1,12 @@
# frozen_string_literal: true
##
# Adds main_group column to store the users group which should be considered when
# calculating group workloads.
#
class AddMainGroupToWlUserData < ActiveRecord::Migration[5.2]
def change
add_column :wl_user_datas, :main_group, :integer
add_index :wl_user_datas, :main_group
end
end

59
init.rb Executable file
View File

@@ -0,0 +1,59 @@
# frozen_string_literal: true
require 'redmine'
require File.expand_path('lib/redmine_workload', __dir__)
Redmine::Plugin.register :redmine_workload do
name 'Redmine workload plugin'
author 'Jost Baron, Liane Hampe, xmera Solutions GmbH'
description 'This is a plugin for Redmine, originally developed by Rafael Calleja. It ' \
'displays the estimated number of hours users and groups have to work to finish ' \
'all their assigned issus on time.'
version '2.2.1'
url 'https://github.com/xmera-circle/redmine_workload'
menu :top_menu,
:WorkLoad,
{ controller: 'workloads', action: 'index' },
caption: :workload_title,
if: proc {
User.current.logged? && User.current.allowed_to?({ controller: :workloads, action: :index },
nil, global: true)
}
settings partial: 'settings/workload_settings',
default: {
'general_workday_monday' => 'checked',
'general_workday_tuesday' => 'checked',
'general_workday_wednesday' => 'checked',
'general_workday_thursday' => 'checked',
'general_workday_friday' => 'checked',
'general_workday_saturday' => '',
'general_workday_sunday' => '',
'threshold_lowload_min' => 0.1,
'threshold_normalload_min' => 7,
'threshold_highload_min' => 8.5,
'workload_of_parent_issues' => ''
}
permission :view_all_workloads, workloads: :index
permission :view_own_workloads, workloads: :index
permission :view_own_group_workloads, workloads: :index
permission :edit_national_holiday, wl_national_holiday: %i[create update destroy]
permission :edit_user_vacations, wl_user_vacations: %i[create update destroy]
permission :edit_user_data, wl_user_datas: :update
end
if Rails.version < '6'
plugin = Redmine::Plugin.find(:redmine_workload)
Rails.application.configure do
config.autoload_paths << "#{plugin.directory}/app/presenters"
end
end
class RedmineToolbarHookListener < Redmine::Hook::ViewListener
def view_layouts_base_html_head(_context)
javascript_include_tag('slides', plugin: :redmine_workload) +
stylesheet_link_tag('style', plugin: :redmine_workload)
end
end

13
lib/redmine_workload.rb Normal file
View File

@@ -0,0 +1,13 @@
# frozen_string_literal: true
require_relative 'redmine_workload/extensions/user_patch'
require_relative 'redmine_workload/hooks/plugin'
require_relative 'redmine_workload/group_workload_preparer'
require_relative 'redmine_workload/user_workload_preparer'
require_relative 'redmine_workload/wl_calculation_restrictions'
require_relative 'redmine_workload/wl_csv_exporter'
require_relative 'redmine_workload/wl_date_tools'
require_relative 'redmine_workload/wl_issue_query'
require_relative 'redmine_workload/wl_issue_state'
require_relative 'redmine_workload/wl_user_data_finder'
require_relative 'redmine_workload/wl_user_data_defaults'

View File

@@ -0,0 +1,37 @@
# frozen_string_literal: true
module RedmineWorkload
module Extensions
module UserPatch
def self.prepended(base)
base.prepend(InstanceMethods)
base.class_eval do
has_one :wl_user_data, inverse_of: :user
has_many :wl_user_vacations, inverse_of: :user
delegate :main_group, to: :wl_user_data, allow_nil: true
end
end
module InstanceMethods
##
# Prefer to use main_group_id over User#wl_user_data.main_group since
# the latter may lead to
# NoMethodError Exception: undefined method `main_group' for nil:NilClass
# when no data set for wl_user_data exists. In contrast, the delegation
# of main_group, as used below, will handle this case.
#
def main_group_id
main_group
end
end
end
end
end
if Rails.version < '6'
Rails.configuration.to_prepare do
unless User.included_modules.include?(RedmineWorkload::Extensions::UserPatch)
User.prepend RedmineWorkload::Extensions::UserPatch
end
end
end

View File

@@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'forwardable'
class GroupWorkloadPreparer
include Redmine::I18n
extend Forwardable
def_delegators :data, :user_workload, :time_span
attr_reader :data, :params
def initialize(data:, params:)
self.data = data
self.params = params
end
def group_workload
data.by_group
end
def type(assignee)
case assignee.class.name
when 'Group'
l(:label_aggregation)
else
assignee.type
end
end
def main_group(assignee)
case assignee.class.name
when 'User'
user_group = assignee.main_group_id
assignee.groups.find_by(id: user_group)&.name
when 'GroupUserDummy'
assignee.main_group&.name
else
''
end
end
private
attr_writer :data, :params
end

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
module RedmineWorkload
module Hooks
class AfterPluginsLoadedHook < Redmine::Hook::Listener
def after_plugins_loaded(_context = {})
return unless Rails.version > '6'
patch = RedmineWorkload::Extensions::UserPatch
klass = User
klass.prepend patch unless klass.included_modules.include?(patch)
end
end
end
end

View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true
require 'forwardable'
class UserWorkloadPreparer
include Redmine::I18n
extend Forwardable
def_delegators :data, :time_span
attr_reader :data, :params
def initialize(data:, params:)
self.data = data
self.params = params
end
def group_workload
{}
end
def user_workload
data.by_user
end
def type(assignee)
assignee.type
end
def main_group(assignee)
user_group = assignee.main_group_id
assignee.groups.find_by(id: user_group)&.name
end
private
attr_writer :data, :params
end

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
module WlCalculationRestrictions
def consider_parent_issues?
settings['workload_of_parent_issues'].present?
end
def settings
Setting.plugin_redmine_workload
end
end

View File

@@ -0,0 +1,108 @@
# frozen_string_literal: true
require 'forwardable'
class WlCsvExporter
include Redmine::I18n
extend Forwardable
def_delegators :data, :group_workload, :user_workload, :type, :time_span, :main_group
attr_reader :data, :params
def initialize(data:, params:)
self.data = initialize_data_object(data)
self.params = params
end
def header_fields
static_column_names.map { |column| l("field_#{column}") } |
dynamic_column_names
end
def line(assignee, workload, status)
send("#{status}_line", assignee, workload)
end
private
attr_writer :data, :params
def initialize_data_object(data)
return unless data
klass = data.class
"#{klass}Preparer".constantize.new(data: data, params: params)
end
def planned_line(assignee, workload)
[l(:label_planned),
type(assignee),
name(assignee),
main_group(assignee),
overdue_issues(workload),
overdue_hours(workload),
unscheduled_issues(workload),
unscheduled_hours(workload),
workload_over_time(workload)].flatten
end
def available_line(assignee, workload)
[l(:label_available),
type(assignee),
name(assignee),
'',
'',
'',
'',
'',
max_capacities_over_time(workload)].flatten
end
def name(assignee)
assignee.name
end
def overdue_issues(workload)
workload[:overdue_number]
end
def overdue_hours(workload)
workload[:overdue_hours]
end
def unscheduled_issues(workload)
workload[:unscheduled_number]
end
def unscheduled_hours(workload)
workload[:unscheduled_hours]
end
def workload_over_time(workload)
data.time_span.map do |day|
workload[:total][day][:hours]
end
end
def max_capacities_over_time(workload)
data.time_span.map do |day|
workload[:total][day][:highload]
end
end
def static_column_names
%w[ status
type
name
main_group
number_of_overdue_issues
number_of_overdue_hours
number_of_unscheduled_issues
number_of_unscheduled_hours]
end
def dynamic_column_names
data.time_span.map { |date| format_date(date) }
end
end

View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
require_relative 'wl_user_data_defaults'
class WlDateTools
extend WlUserDataDefaults
##
# Returns an array with one entry for each month in the given time span.
# Each entry is a hash with two keys: :first_day and :last_day, having the
# first resp. last day of that month from the time span as value.
# @param time_span [Range] Time span
# @return [Array(Hash)] Array with one entry for each month in the given time span
#
def self.months_in_time_span(time_span)
raise ArgumentError unless time_span.is_a?(Range)
# Abort if the given time span is empty.
return [] unless time_span.any?
first_of_current_month = time_span.first
last_of_current_month = [first_of_current_month.end_of_month, time_span.last].min
result = []
while first_of_current_month <= time_span.last
result.push({
first_day: first_of_current_month,
last_day: last_of_current_month
})
first_of_current_month = first_of_current_month.beginning_of_month.next_month
last_of_current_month = [first_of_current_month.end_of_month, time_span.last].min
end
result
end
# Returns a list of all regular working weekdays.
# 1 is monday, 7 is sunday (same as in Date::cwday)
def self.working_days
result = Set.new
result.add(1) if settings['general_workday_monday'] != ''
result.add(2) if settings['general_workday_tuesday'] != ''
result.add(3) if settings['general_workday_wednesday'] != ''
result.add(4) if settings['general_workday_thursday'] != ''
result.add(5) if settings['general_workday_friday'] != ''
result.add(6) if settings['general_workday_saturday'] != ''
result.add(7) if settings['general_workday_sunday'] != ''
result
end
def self.working_days_in_time_span(time_span, assignee, no_cache: false)
raise ArgumentError unless time_span.is_a?(Range)
Rails.cache.clear if no_cache
Rails.cache.fetch("#{assignee.id}/#{time_span}", expires_in: 12.hours) do
result = Set.new
time_span.each do |day|
next if vacation?(day, assignee)
next if holiday?(day)
result.add(day) if working_days.include?(day.cwday)
end
result
end
end
def self.real_distance_in_days(time_span, assignee)
raise ArgumentError unless time_span.is_a?(Range)
working_days_in_time_span(time_span, assignee).size
end
def self.holiday?(day)
!WlNationalHoliday.where('start <= ? AND end >= ?', day, day).empty?
end
def self.vacation?(day, assignee)
return false unless assignee.is_a?(User)
!WlUserVacation.where('user_id = ? AND date_from <= ? AND date_to >= ?', assignee.id, day, day).empty?
end
end

View File

@@ -0,0 +1,40 @@
# frozen_string_literal: true
module WlIssueQuery
include WlCalculationRestrictions
##
# Returns all issues that fulfill the following conditions:
# * They are open
# * The project they belong to is active
# * They have no children if consider_parent_issues? is false
# * They are parent or child isse if consider_parent_issues? is true
#
# @param users [Array(User)] An array of user objects.
# @return [Array(Issue)] The set of issues meeting the conditions above.
#
def open_issues_for_users(users, issues = nil)
return issues if issues
raise ArgumentError unless users.is_a?(Array)
user_ids = users.map(&:id)
issue = Issue.arel_table
project = Project.arel_table
issue_status = IssueStatus.arel_table
# Fetch all issues that ...
issues = Issue.joins(:project)
.joins(:status)
.joins(:assigned_to)
.where(issue[:assigned_to_id].in(user_ids)) # Are assigned to one of the interesting users
.where(project[:status].eq(1)) # Do not belong to an inactive project
.where(issue_status[:is_closed].eq(false)) # Is open
# Filter out all issues that have children
return issues.select(&:leaf?) unless consider_parent_issues?
# Contains parent and child issues
issues.split.flatten
end
end

View File

@@ -0,0 +1,12 @@
# frozen_string_literal: true
module WlIssueState
##
# Redefines the overdue state of an issue. Instead of comparing issue.due_date
# with the User.current.today (as in Issue#overdue?) it will compare by the
# date given.
#
def issue_overdue?(issue, date)
issue.due_date.present? && (issue.due_date < date) && !issue.closed?
end
end

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
##
# Provides the default values for WlUserData object
#
module WlUserDataDefaults
def default_attributes
{ threshold_lowload_min: settings['threshold_lowload_min'],
threshold_normalload_min: settings['threshold_normalload_min'],
threshold_highload_min: settings['threshold_highload_min'] }
end
def settings
Setting['plugin_redmine_workload']
end
end

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
##
# Finder method for WLUserData
#
module WlUserDataFinder
##
# Finds the workload data of the current user or creates them if not found.
# When @user_workload_data is a new record default values will be assigned.
#
def find_user_workload_data(user_id = User.current.id)
@user_workload_data = WlUserData.find_or_create_by(user_id: user_id)
@user_workload_data.update_to_defaults_when_new
@user_workload_data
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

34
test/authenticate_user.rb Executable file
View File

@@ -0,0 +1,34 @@
# frozen_string_literal: true
module RedmineWorkload
##
# Provide user login test
#
module AuthenticateUser
def log_user(login, password)
login_page
log_user_in(login, password)
assert_equal login, User.find(user_session_id).login
end
module_function
def login_page
User.anonymous
get '/login'
assert_nil user_session_id
assert_response :success
end
def user_session_id
session[:user_id]
end
def log_user_in(login, password)
post '/login', params: {
username: login,
password: password
}
end
end
end

View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
require File.expand_path('../test_helper', __dir__)
class WlNationalHolidayControllerTest < ActionDispatch::IntegrationTest
include RedmineWorkload::AuthenticateUser
fixtures :trackers, :projects, :projects_trackers, :members, :member_roles,
:users, :issue_statuses, :enumerations, :roles
test 'should get index' do
log_user('jsmith', 'jsmith')
get wl_national_holiday_index_path
assert_response :success
end
test 'should not create new holiday when user is not allowed to' do
log_user('jsmith', 'jsmith')
post wl_national_holiday_index_path
assert_response :forbidden
end
test 'should create new national holiday' do
manager = roles :roles_001
manager.add_permission! :edit_national_holiday
log_user('jsmith', 'jsmith')
post wl_national_holiday_index_path,
params: { wl_national_holiday:
{ start: today, end: tomorrow, reason: 'Eastern' } }
assert_redirected_to wl_national_holiday_index_path
end
test 'should not update holiday when user is not allowed to' do
holiday = generate_holiday
log_user('jsmith', 'jsmith')
patch wl_national_holiday_path(id: holiday.id),
params: { wl_national_holiday: { reason: 'New Year' } }
assert_response :forbidden
end
test 'should update holiday' do
holiday = generate_holiday
manager = roles :roles_001
manager.add_permission! :edit_national_holiday
log_user('jsmith', 'jsmith')
patch wl_national_holiday_path(id: holiday.id),
params: { wl_national_holiday: { reason: 'New Year' } }
assert_redirected_to wl_national_holiday_index_path
end
test 'should not destroy holiday when user is not allowed to' do
holiday = generate_holiday
log_user('jsmith', 'jsmith')
delete wl_national_holiday_path(id: holiday.id)
assert_response :forbidden
end
test 'should destroy holiday' do
holiday = generate_holiday
manager = roles :roles_001
manager.add_permission! :edit_national_holiday
log_user('jsmith', 'jsmith')
delete wl_national_holiday_path(id: holiday.id)
assert_redirected_to wl_national_holiday_index_path
end
private
def generate_holiday
WlNationalHoliday.create(start: today, end: tomorrow, reason: 'Christmas')
end
def today
Time.zone.today
end
def tomorrow
today + 1
end
end

View File

@@ -0,0 +1,81 @@
# frozen_string_literal: true
require File.expand_path('../test_helper', __dir__)
class WlUserDatasControllerTest < ActionDispatch::IntegrationTest
include RedmineWorkload::AuthenticateUser
include WlUserDataFinder
fixtures :trackers, :projects, :projects_trackers, :members, :member_roles,
:users, :issue_statuses, :enumerations, :roles
def setup
find_user_workload_data
@group = Group.generate!
end
test 'should render edit' do
log_user('jsmith', 'jsmith')
get edit_wl_user_data_path(@user_workload_data)
assert_response :success
assert_match(/jsmith/, response.body)
end
test 'should update data if user allowed to' do
jsmith = users :users_002
manager = roles :roles_001
manager.add_permission! :edit_user_data
jsmith.groups << @group
log_user('jsmith', 'jsmith')
patch wl_user_data_path(@user_workload_data),
params: { wl_user_data: to_be_updated(@group.id) }
assert_redirected_to controller: :workloads, action: :index
wl_user_data = WlUserData.find_by(user_id: jsmith.id)
current = wl_user_data.attributes
expected = { 'id' => wl_user_data.id,
'user_id' => jsmith.id,
'threshold_lowload_min' => 2.0,
'threshold_normalload_min' => 4.0,
'threshold_highload_min' => 6.0,
'main_group' => @group.id }
assert_equal expected, current
end
test 'should not update data if user not allowed to' do
jsmith = users :users_002
jsmith.groups << @group
log_user('jsmith', 'jsmith')
patch wl_user_data_path(@user_workload_data),
params: { wl_user_data: to_be_updated(@group.id) }
assert :forbidden
end
test 'should render errors messages when user updates with foreign group' do
jsmith = users :users_002
manager = roles :roles_001
manager.add_permission! :edit_user_data
jsmith.groups << @group
log_user('jsmith', 'jsmith')
patch wl_user_data_path(@user_workload_data),
params: { wl_user_data: to_be_updated(1) }
assert :success
# Use this assertion when error rendering is refactored!
assert_select_error(/is not included in the list/)
end
private
def to_be_updated(group_id)
{ threshold_lowload_min: 2,
threshold_normalload_min: 4,
threshold_highload_min: 6,
main_group: group_id }
end
end

View File

@@ -0,0 +1,89 @@
# frozen_string_literal: true
require File.expand_path('../test_helper', __dir__)
class WlUserVacationsControllerTest < ActionDispatch::IntegrationTest
include RedmineWorkload::AuthenticateUser
fixtures :trackers, :projects, :projects_trackers, :members, :member_roles,
:users, :issue_statuses, :enumerations, :roles
test 'should get index' do
log_user('jsmith', 'jsmith')
get wl_user_vacations_path
assert_response :success
end
test 'should not create new vacation when user is not allowed to' do
log_user('jsmith', 'jsmith')
post wl_user_vacations_path,
params: { wl_user_vacations:
{ date_from: today, date_to: tomorrow, comment: 'Eastern' } }
assert_response :forbidden
end
test 'should create new vacation' do
manager = roles :roles_001
manager.add_permission! :edit_user_vacations
log_user('jsmith', 'jsmith')
post wl_user_vacations_path,
params: { wl_user_vacations:
{ date_from: today, date_to: tomorrow, comment: 'Eastern' } }
assert_redirected_to wl_user_vacations_path
end
test 'should not update vacation when user is not allowed to' do
vacation = generate_vacation
log_user('jsmith', 'jsmith')
patch wl_user_vacation_path(id: vacation.id),
params: { wl_user_vacation: { comment: 'No comment' } }
assert_response :forbidden
end
test 'should update vacation' do
vacation = generate_vacation
manager = roles :roles_001
manager.add_permission! :edit_user_vacations
log_user('jsmith', 'jsmith')
patch wl_user_vacation_path(id: vacation.id),
params: { wl_user_vacation: { comment: 'No comment' } }
assert_redirected_to wl_user_vacations_path
end
test 'should not destroy vacation when user is not allowed to' do
vacation = generate_vacation
log_user('jsmith', 'jsmith')
delete wl_user_vacation_path(id: vacation.id)
assert_response :forbidden
end
test 'should destroy vacation' do
vacation = generate_vacation
manager = roles :roles_001
manager.add_permission! :edit_user_vacations
log_user('jsmith', 'jsmith')
delete wl_user_vacation_path(id: vacation.id)
assert_redirected_to wl_user_vacations_path
end
private
def generate_vacation
WlUserVacation.create(user_id: 2, date_from: today, date_to: tomorrow, comments: 'Private')
end
def today
Time.zone.today
end
def tomorrow
today + 1
end
end

View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
require File.expand_path('../test_helper', __dir__)
class WorkloadsControllerTest < ActionDispatch::IntegrationTest
include RedmineWorkload::AuthenticateUser
fixtures :trackers, :projects, :projects_trackers, :members, :member_roles,
:users, :issue_statuses, :enumerations, :roles
test 'should not get index when not allowed to' do
log_user('jsmith', 'jsmith')
get workloads_path
assert_response :forbidden
end
test 'should get index' do
manager = roles :roles_001
manager.add_permission! :view_all_workloads
log_user('jsmith', 'jsmith')
get workloads_path
assert_response :success
end
test 'should get index with format csv' do
manager = roles :roles_001
manager.add_permission! :view_all_workloads
log_user('jsmith', 'jsmith')
get workloads_path(format: 'csv')
assert_response :success
end
end

View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
require File.expand_path('../../test_helper', __dir__)
class RoutingWlUserDataTest < Redmine::RoutingTest
def test_wl_user_data
should_route 'GET /wl_user_datas/1/edit' => 'wl_user_datas#edit', id: '1'
should_route 'PATCH /wl_user_datas/1' => 'wl_user_datas#update', id: '1'
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
require File.expand_path('../../test_helper', __dir__)
class RoutingWorkloadTest < Redmine::RoutingTest
def test_workloads
should_route 'GET /workloads' => 'workloads#index'
end
end

7
test/test_helper.rb Normal file
View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
# Load the normal Rails helper
require File.expand_path('../../../test/test_helper', __dir__)
# Load other test helper modules
require File.expand_path('authenticate_user', __dir__)
require File.expand_path('workload_object_helper', __dir__)

View File

@@ -0,0 +1,59 @@
# frozen_string_literal: true
require File.expand_path('../test_helper', __dir__)
class WlGroupSelectionTest < ActiveSupport::TestCase
fixtures :trackers, :projects, :projects_trackers, :members, :member_roles,
:users, :issue_statuses, :enumerations, :roles
def setup
Group.where.not(id: [12, 13]).delete_all
@build_in_groups = Group.where(id: [12, 13])
@groups = 5.times.map { |count| Group.generate! if count }
end
test 'should return all groups if the current user is admin' do
admin = users :users_001 # admin
groups = WlGroupSelection.new(user: admin)
expected = (@groups.map(&:id) | @build_in_groups.map(&:id)).uniq.sort
current = groups.allowed_to_display.map(&:id).sort
assert_equal expected, current
end
test 'should return all groups when user has permission :view_all_workloads' do
current_user = users :users_002 # jsmith
manager = roles :roles_001 # manager
manager.add_permission! :view_all_workloads
groups = WlGroupSelection.new(user: current_user)
expected = (@groups.map(&:id) | @build_in_groups.map(&:id)).uniq.sort
current = groups.allowed_to_display.map(&:id).sort
assert_equal expected, current
end
test 'should return current users groups when allowed to :view_own_group_workloads' do
group1 = Group.generate!
group2 = Group.generate!
group3 = Group.generate!
user1 = User.generate!
user1.groups << group1
user2 = User.generate!
user2.groups << group2
user3 = User.generate!
user3.groups << group3
current_user = users :users_002 # jsmith
current_user.groups << [group1, group3]
manager = roles :roles_001 # manager
manager.add_permission! :view_own_group_workloads
groups = WlGroupSelection.new(user: current_user)
expected = [group1, group3].map(&:id).sort
current = groups.allowed_to_display.map(&:id).sort
assert_equal expected, current
end
test 'should return an empty array if the current user has no permission to view workloads' do
groups = WlGroupSelection.new(user: User.anonymous)
assert_equal [], groups.allowed_to_display
end
end

View File

@@ -0,0 +1,104 @@
# frozen_string_literal: true
require File.expand_path('../test_helper', __dir__)
class GroupUserDummyTest < ActiveSupport::TestCase
include WlUserDataFinder
include WorkloadsHelper
fixtures :trackers, :projects, :projects_trackers, :members, :member_roles,
:users, :issue_statuses, :enumerations, :roles
def setup
@group = Group.generate!
@user1 = User.generate!
@user1.groups << @group
@user1_wl_data = find_user_workload_data(@user1.id)
@user1_wl_data.main_group = @group.id
@user1_wl_data.save
@user2 = User.generate!
@user2.groups << @group
@user2_wl_data = find_user_workload_data(@user2.id)
@user2_wl_data.main_group = @group.id
@user2_wl_data.save
@dummy = GroupUserDummy.new(group: @group)
end
test 'should respond to group' do
assert @dummy.respond_to? :group
end
test 'should respond to lastname' do
assert @dummy.respond_to? :lastname
end
test 'should respond to name' do
assert @dummy.respond_to? :name
end
test 'should respond to main_group' do
assert @dummy.respond_to? :main_group
end
test 'should respond to type' do
assert @dummy.respond_to? :type
end
test 'should respond to threshold_lowload_min' do
assert @dummy.respond_to? :threshold_lowload_min
end
test 'should respond to threshold_normalload_min' do
assert @dummy.respond_to? :threshold_normalload_min
end
test 'should respond to threshold_highload_min' do
assert @dummy.respond_to? :threshold_highload_min
end
test 'should sum up threshold of group members when unset' do
group = Group.generate!
user1 = User.generate!
user1.groups << group
user1_wl_data = WlUserData.new(user_id: user1.id)
user1_wl_data.main_group = group.id
user1_wl_data.save
user2 = User.generate!
user2.groups << group
user2_wl_data = WlUserData.new(user_id: user2.id)
user2_wl_data.main_group = group.id
user2_wl_data.save
dummy = GroupUserDummy.new(group: group)
expected = 0.0
thresholds = %i[threshold_lowload_min threshold_normalload_min threshold_highload_min]
thresholds.each do |threshold|
current = dummy.send :sum_up, threshold
assert_equal expected, current
end
end
test 'should sum up thresholds of group members when given' do
lowload1, normalload1, highload1 = [2, 4, 6]
@user1_wl_data.threshold_lowload_min = lowload1
@user1_wl_data.threshold_normalload_min = normalload1
@user1_wl_data.threshold_highload_min = highload1
@user1_wl_data.save
lowload2, normalload2, highload2 = [3, 5, 7]
@user2_wl_data.threshold_lowload_min = lowload2
@user2_wl_data.threshold_normalload_min = normalload2
@user2_wl_data.threshold_highload_min = highload2
@user2_wl_data.save
expected_threshold_lowload_min = lowload1 + lowload2
current_threshold_lowload_min = @dummy.send :sum_up, :threshold_lowload_min
assert_equal expected_threshold_lowload_min, current_threshold_lowload_min
expected_threshold_normalload_min = normalload1 + normalload2
current_threshold_normalload_min = @dummy.send :sum_up, :threshold_normalload_min
assert_equal expected_threshold_normalload_min, current_threshold_normalload_min
expected_threshold_highload_min = highload1 + highload2
current_threshold_highload_min = @dummy.send :sum_up, :threshold_highload_min
assert_equal expected_threshold_highload_min, current_threshold_highload_min
end
end

View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
require File.expand_path('../test_helper', __dir__)
class GroupWorkloadPreparerTest < ActiveSupport::TestCase
include Redmine::I18n
test 'should respond to user_workload' do
preparer = GroupWorkloadPreparer.new(data: {}, params: {})
assert preparer.respond_to? :user_workload
end
test 'should repsond to time_span' do
preparer = GroupWorkloadPreparer.new(data: {}, params: {})
assert preparer.respond_to? :time_span
end
test 'should repsond to group_workload' do
preparer = GroupWorkloadPreparer.new(data: {}, params: {})
assert preparer.respond_to? :group_workload
end
test 'should return type of assignee' do
preparer = GroupWorkloadPreparer.new(data: {}, params: {})
assert_equal l(:label_aggregation), preparer.type(Group.generate!)
assert_equal 'User', preparer.type(User.generate!)
end
test 'should return main group of assignee' do
group = Group.generate!
dummy = GroupUserDummy.new(group: group)
user = User.generate!
user.groups << group
user.create_wl_user_data(main_group: group.id)
preparer = GroupWorkloadPreparer.new(data: {}, params: {})
assert_equal group.name, preparer.main_group(user)
assert_equal dummy.main_group.name, preparer.main_group(dummy)
end
end

View File

@@ -0,0 +1,233 @@
# frozen_string_literal: true
require File.expand_path('../test_helper', __dir__)
class GroupWorkloadTest < ActiveSupport::TestCase
include RedmineWorkload::WorkloadObjectHelper
fixtures :roles, :projects, :issue_statuses, :trackers, :enumerations, :users
def setup
@user = users :users_001
@manager = roles :roles_001
@manager.add_permission! :view_all_workloads
end
test 'should respond to by_group' do
group_workload = prepare_group_workload(user: @user,
role: @manager,
groups: groups_defined,
main_group_strategy: :distinct,
vacation_strategy: :distinct)
assert group_workload.respond_to?(:by_group)
end
test 'should respond to time_span' do
group_workload = prepare_group_workload(user: @user,
role: @manager,
groups: groups_defined,
main_group_strategy: :distinct,
vacation_strategy: :distinct)
assert group_workload.respond_to?(:time_span)
end
test 'should respond to user_workload' do
group_workload = prepare_group_workload(user: @user,
role: @manager,
groups: groups_defined,
main_group_strategy: :distinct,
vacation_strategy: :distinct)
assert group_workload.respond_to?(:user_workload)
end
test 'define_group_members should return empty hash when no groups selected' do
empty_group_workload = prepare_group_workload(user: @user,
role: @manager,
main_group_strategy: :distinct,
vacation_strategy: :distinct)
expected = {}
current = empty_group_workload.send :group_members
assert_equal expected, current
end
test 'should select group members only once' do
group_workload = prepare_group_workload(user: @user,
role: @manager,
groups: groups_defined,
main_group_strategy: :distinct,
vacation_strategy: :distinct,
group_user_dummy_strategy: true)
selected_groups = group_workload.send(:selected_groups)
group1 = selected_groups.first
group2 = selected_groups.last
group_members = group_workload.send(:group_members)
member_list1 = group_members[group1].keys.map(&:id)
member_list2 = group_members[group2].keys.map(&:id)
count = (member_list1 | member_list2).count
assert_equal 3, count
current = member_list1 & member_list2
expected = []
assert_equal expected, current
end
test 'should list group_user_dummy first' do
group_workload = prepare_group_workload(user: @user,
role: @manager,
groups: groups_defined,
main_group_strategy: :distinct,
vacation_strategy: :distinct,
group_user_dummy_strategy: true)
sorted_user_workload = group_workload.send(:sorted_user_workload)
assert sorted_user_workload.keys.first.is_a? GroupUserDummy
end
test 'should return no holiday when only one group member is on vacation for a given day' do
group_workload = prepare_group_workload(user: @user,
role: @manager,
groups: groups_defined,
main_group_strategy: :same,
vacation_strategy: :distinct)
user1_id = group_workload.send(:users).send(:users).first
user1 = User.find(user1_id)
assert user1.is_a? User
group = Group.find(user1.main_group_id)
## The following checks are only required for the error analysis if any
# assert user1.wl_user_vacations.where(date_from: first_day, date_to: first_day).take.presence
# assert WlUserVacation.where(user_id: user1.id, date_from: first_day, date_to: first_day).take.presence
# user2_id = group_workload.send(:users).send(:users).last
# user2 = User.find(user2_id)
# assert user2.is_a? User
# assert_not_equal user1, user2
# assert_equal group.id, user2.main_group_id
# assert user2.wl_user_vacations.where(date_from: last_day, date_to: last_day).take.presence
# assert WlUserVacation.where(user_id: user2.id, date_from: last_day, date_to: last_day).take.presence
# assert_equal 3, group_workload.user_workload.keys.count # GroupUserDummy + user1 + user2
# assert_equal 3, group_workload.send(:group_members)[group].keys.count
## end
expected = false
current = group_workload.send(:holiday_at, first_day, :total, group)
assert_equal expected, current
end
test 'should return holiday when all group members are on vacation for a given day' do
# and no group user dummy exists
group_workload = prepare_group_workload(user: @user,
role: @manager,
groups: groups_defined,
main_group_strategy: :same,
vacation_strategy: :same)
user1_id = group_workload.send(:users).send(:users).first
user1 = User.find(user1_id)
assert user1.is_a? User
group = Group.find(user1.main_group_id)
## The following checks are only required for the error analysis if any
# assert user1.wl_user_vacations.where(date_from: first_day, date_to: first_day).take.presence
# assert WlUserVacation.where(user_id: user1.id, date_from: first_day, date_to: first_day).take.presence
# user2_id = group_workload.send(:users).send(:users).last
# user2 = User.find(user2_id)
# assert user2.is_a? User
# assert_equal group.id, user2.main_group_id
# assert user2.wl_user_vacations.where(date_from: first_day, date_to: first_day).take.presence
# assert WlUserVacation.where(user_id: user2.id, date_from: first_day, date_to: first_day).take.presence
# assert_equal 3, group_workload.user_workload.keys.count # GroupUserDummy + user1 + user2
# assert_equal 3, group_workload.send(:group_members)[group].keys.count
## end
expected = true
current = group_workload.send(:holiday_at, first_day, :total, group)
assert_equal expected, current
end
test 'should return no holiday when all group members are on holiday except group user dummy' do
# group user dummy cannot go to holiday
group_workload = prepare_group_workload(user: @user,
role: @manager,
groups: groups_defined,
main_group_strategy: :same,
vacation_strategy: :same,
group_user_dummy_strategy: true)
user1_id = group_workload.send(:users).send(:users).first
user1 = User.find(user1_id)
assert user1.is_a? User
group = Group.find(user1.main_group_id)
expected = false
current = group_workload.send(:holiday_at, first_day, :total, group)
assert_equal expected, current
end
test 'should count unscheduled issues and hours on group level' do
group1, group2 = groups_defined
workload = prepare_group_workload(user: @user,
role: @manager,
groups: [group1, group2],
main_group_strategy: :distinct,
vacation_strategy: :distinct,
group_user_dummy_strategy: true)
assert_equal 2, workload.by_group[group1][:unscheduled_number]
assert_equal 24.0, workload.by_group[group1][:unscheduled_hours]
assert_equal 0, workload.by_group[group2][:unscheduled_number]
assert_equal 0.0, workload.by_group[group2][:unscheduled_hours]
end
test 'should calculate day dependent threshold values for group workload without dummy' do
group1, group2 = groups_defined
workload = prepare_group_workload(user: @user,
role: @manager,
groups: [group1, group2],
main_group_strategy: :same, # group1
# first_day for one user and last_day for the other
vacation_strategy: :distinct)
# threshold default values: highload = 6, lowload = 3, normalload = 4
# holiday for one of two group members
assert_equal 3.0, workload.send(:threshold_at, first_day, :lowload, group1)
assert_equal 4.0, workload.send(:threshold_at, first_day, :normalload, group1)
assert_equal 6.0, workload.send(:threshold_at, first_day, :highload, group1)
# Saturday
assert_equal 0.0, workload.send(:threshold_at, first_day + 3, :lowload, group1)
assert_equal 0.0, workload.send(:threshold_at, first_day + 3, :normalload, group1)
assert_equal 0.0, workload.send(:threshold_at, first_day + 3, :highload, group1)
# both group members are working
assert_equal 6.0, workload.send(:threshold_at, first_day + 1, :lowload, group1)
assert_equal 8.0, workload.send(:threshold_at, first_day + 1, :normalload, group1)
assert_equal 12.0, workload.send(:threshold_at, first_day + 1, :highload, group1)
end
test 'should calculate day dependent threshold values for group workload with dummy' do
group1, group2 = groups_defined
workload = prepare_group_workload(user: @user,
role: @manager,
groups: [group1, group2],
main_group_strategy: :same, # group1
# first_day for one user and last_day for the other
vacation_strategy: :distinct,
group_user_dummy_strategy: true)
# threshold default values: highload = 6, lowload = 3, normalload = 4
# holiday for one of two group members
assert_equal 3.0, workload.send(:threshold_at, first_day, :lowload, group1)
assert_equal 4.0, workload.send(:threshold_at, first_day, :normalload, group1)
assert_equal 6.0, workload.send(:threshold_at, first_day, :highload, group1)
# Saturday
assert_equal 0.0, workload.send(:threshold_at, first_day + 3, :lowload, group1)
assert_equal 0.0, workload.send(:threshold_at, first_day + 3, :normalload, group1)
assert_equal 0.0, workload.send(:threshold_at, first_day + 3, :highload, group1)
# both group members are working
assert_equal 6.0, workload.send(:threshold_at, first_day + 1, :lowload, group1)
assert_equal 8.0, workload.send(:threshold_at, first_day + 1, :normalload, group1)
assert_equal 12.0, workload.send(:threshold_at, first_day + 1, :highload, group1)
end
end

View File

@@ -0,0 +1,46 @@
# frozen_string_literal: true
require File.expand_path('../../test_helper', __dir__)
class WlIssueQueryTest < ActiveSupport::TestCase
include RedmineWorkload::WorkloadObjectHelper
include WlIssueQuery
fixtures :trackers, :projects, :projects_trackers, :members, :member_roles,
:users, :issue_statuses, :enumerations, :roles
def setup
@manager = roles :roles_001
@user1 = User.generate!
@user2 = User.generate!
@project1 = Project.generate!
User.add_to_project(@user1, @project1, @manager)
User.add_to_project(@user2, @project1, @manager)
@status_new = IssueStatus.find(1)
@parent_issue = Issue.generate!(assigned_to: @user1,
status: @status_new,
project: @project1)
@child_issue = Issue.generate!(assigned_to: @user1,
status: @status_new,
project: @project1,
parent_issue_id: @parent_issue.id)
end
def teardown
@child_issue.destroy
@parent_issue.destroy
@user1.destroy
@user2.destroy
@project1.destroy
Setting.clear_cache
end
test 'should query parent and child issues if any' do
with_plugin_settings 'workload_of_parent_issues' => 'checked' do
expected_ids = [@parent_issue.id, @child_issue.id]
query_result = open_issues_for_users([@user1, @user2])
query_result_ids = query_result.pluck(:id)
assert_equal expected_ids, query_result_ids
end
end
end

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
require File.expand_path('../test_helper', __dir__)
class UserPatchTest < ActiveSupport::TestCase
test 'should respond to wl_user_data' do
user = User.generate!
assert user.respond_to? :wl_user_data
end
test 'should repsond to wl_user_vacations' do
user = User.generate!
assert user.respond_to? :wl_user_vacations
end
end

View File

@@ -0,0 +1,110 @@
# frozen_string_literal: true
require File.expand_path('../test_helper', __dir__)
class WlUserSelectionTest < ActiveSupport::TestCase
include WlUserDataDefaults
fixtures :trackers, :projects, :projects_trackers, :members, :member_roles,
:users, :issue_statuses, :enumerations, :roles
def setup
@group1 = Group.generate!
@group2 = Group.generate!
@group3 = Group.generate!
@user1 = User.generate!
@user1.groups << @group1
@user2 = User.generate!
@user2.groups << @group2
@user3 = User.generate!
@user3.groups << @group3
@group_member_ids = []
@group_member_ids << @group1.users.map(&:id)
@group_member_ids << @group2.users.map(&:id)
@group_member_ids << @group3.users.map(&:id)
@group_member_ids
end
test 'should return all users if the current user is admin' do
current_user = User.generate!(admin: true)
groups = WlGroupSelection.new(user: current_user, groups: [@group1.id, @group2.id, @group3.id])
users = WlUserSelection.new(user: current_user, group_selection: groups)
assert_equal @group_member_ids.flatten.sort, users.allowed_to_display.map(&:id).sort
end
test 'should return all active users when user has permission :view_all_workloads' do
current_user = users :users_002 # jsmith
manager = roles :roles_001 # manager
manager.add_permission! :view_all_workloads
groups = WlGroupSelection.new(user: current_user, groups: [@group1.id, @group2.id, @group3.id])
users = WlUserSelection.new(user: current_user, group_selection: groups)
expected = @group_member_ids.flatten.sort
current = users.send(:all_users).map(&:id).sort
assert_equal expected, current
end
test 'should return users of the current users groups when allowed to :view_own_group_workloads' do
current_user = users :users_002 # jsmith
current_user.groups << @group1
current_user.groups << @group3
current_user.create_wl_user_data(default_attributes.merge(main_group: @group1.id))
assert_equal @group1.id, current_user.wl_user_data.main_group
manager = roles :roles_001 # manager
manager.add_permission! :view_own_group_workloads
groups = WlGroupSelection.new(user: current_user, groups: [@group1.id, @group2.id, @group3.id])
users = WlUserSelection.new(user: current_user, group_selection: groups)
expected = [@user1, @user3].map(&:id).sort
current = users.allowed_to_display.map(&:id).sort
assert_equal expected, current
end
test 'should return the current user if allowed to :view_own_workloads' do
current_user = users :users_002 # jsmith
manager = roles :roles_001 # manager
manager.add_permission! :view_own_workloads
groups = WlGroupSelection.new(user: current_user, groups: [@group1.id, @group2.id, @group3.id])
users = WlUserSelection.new(user: current_user, group_selection: groups)
assert_equal [current_user.id], users.allowed_to_display.map(&:id)
end
test 'should return an empty array if the current user has no permission to view workloads' do
current_user = User.anonymous
groups = WlGroupSelection.new(user: current_user, groups: [@group1.id, @group2.id, @group3.id])
users = WlUserSelection.new(user: current_user, group_selection: groups)
assert_equal [], users.allowed_to_display
end
test 'should return current user if no other users given' do
current_user = users :users_002 # jsmith
manager = roles :roles_001 # manager
manager.add_permission! :view_all_workloads
groups = WlGroupSelection.new(user: current_user, groups: [])
users = WlUserSelection.new(user: current_user, group_selection: groups)
expected = [current_user]
current = users.send(:include_current_user)
assert_equal expected, current
end
test 'should not return current user if not selected' do
@user1.create_wl_user_data(default_attributes.merge(main_group: @group1.id))
current_user = users :users_002 # jsmith
manager = roles :roles_001 # manager
manager.add_permission! :view_all_workloads
groups = WlGroupSelection.new(user: current_user, groups: [@group1.id, @group2.id, @group3.id])
users = WlUserSelection.new(user: current_user, group_selection: groups)
expected = []
current = users.send(:include_current_user)
assert_equal expected, current
groups = WlGroupSelection.new(user: current_user, groups: [])
users = WlUserSelection.new(user: current_user, users: [@user1.id, @user2.id, @user3.id], group_selection: groups)
expected = []
current = users.send(:include_current_user)
assert_equal expected, current
end
end

Some files were not shown because too many files have changed in this diff Show More