Skip to content

Commit 4c6f470

Browse files
authored
Merge pull request #24561 from opf/feature-costs-161
[COSTS-161] Add global restrictions for time entries
2 parents ee4715b + 683f736 commit 4c6f470

17 files changed

Lines changed: 1192 additions & 17 deletions

File tree

app/models/user_working_hours.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ class UserWorkingHours < ApplicationRecord
3232
DAYS = %i[monday tuesday wednesday thursday friday saturday sunday].freeze
3333
# Maps each day symbol to the Rails I18n date.abbr_day_names index (Sunday = 0)
3434
DAY_ABBR_INDEX = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 }.freeze
35+
# Date#wday shares the Sunday = 0 numbering of DAY_ABBR_INDEX
36+
WDAY_TO_DAY = DAY_ABBR_INDEX.invert.freeze
3537

3638
belongs_to :user, inverse_of: :working_hours
3739

@@ -79,6 +81,15 @@ def self.current
7981
end
8082
end
8183

84+
def minutes_on(date)
85+
public_send(WDAY_TO_DAY.fetch(date.wday))
86+
end
87+
88+
# The capacity for that day, i.e. the working minutes reduced by the availability factor.
89+
def effective_minutes_on(date)
90+
((minutes_on(date) * availability_factor) / 100.0).round
91+
end
92+
8293
def weekly_working_hours
8394
DAYS.sum { |day| public_send("#{day}_hours") }
8495
end

modules/costs/app/components/cost_settings/show_page_header_component.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def breadcrumb_items
3434
[
3535
{ href: admin_index_path, text: t("label_administration") },
3636
{ href: admin_time_settings_path, text: t(:project_module_costs), skip_for_mobile: true },
37-
t(:label_defaults)
37+
t(:label_defaults_and_limits)
3838
]
3939
end
4040

modules/costs/app/contracts/time_entries/base_contract.rb

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ module TimeEntries
3232
class BaseContract < ::ModelContract
3333
include AssignableValuesContract
3434
include AssignableCustomFieldValues
35+
include PastMonthRestriction
3536

3637
delegate :entity,
3738
:project,
@@ -44,6 +45,7 @@ def self.model
4445
end
4546

4647
validate :validate_hours_are_in_range
48+
validate :validate_spent_on_is_working_day
4749
validate :validate_project_is_set
4850
validate :validate_entity
4951
validate :validate_user
@@ -107,7 +109,76 @@ def validate_user
107109
end
108110

109111
def validate_hours_are_in_range
110-
errors.add :hours, :invalid if model.hours&.negative?
112+
return errors.add(:hours, :invalid) if model.hours&.negative?
113+
114+
validate_hours_within_max_per_entry
115+
validate_hours_within_max_per_day
116+
validate_hours_within_user_working_hours
117+
end
118+
119+
def validate_hours_within_max_per_entry
120+
limit = TimeEntry.max_hours_per_entry
121+
return if limit.nil? || model.hours.nil? || model.hours <= limit
122+
123+
errors.add :hours, :max_hours_per_entry_exceeded, limit:
124+
end
125+
126+
def validate_hours_within_max_per_day
127+
limit = TimeEntry.max_hours_per_day
128+
return if limit.nil? || !day_total_determinable?
129+
return if hours_already_logged_on_day + model.hours <= limit
130+
131+
errors.add :hours, :max_hours_per_day_exceeded, limit:
132+
end
133+
134+
def day_total_determinable?
135+
model.hours.present? && model.spent_on.present? && model.user.present?
136+
end
137+
138+
def hours_already_logged_on_day
139+
TimeEntry.of_user_and_day(model.user, model.spent_on, excluding: model).sum(:hours)
140+
end
141+
142+
# Compared in whole minutes, since that is the granularity time is logged in and how the
143+
# schedule stores its hours. Users without a working hours schedule are not restricted at
144+
# all, so that enabling the setting does not block logging on instances that defined none.
145+
def validate_hours_within_user_working_hours
146+
return unless TimeEntry.limit_to_user_working_hours?
147+
return unless day_total_determinable?
148+
149+
capacity = user_capacity_in_minutes_on(model.spent_on)
150+
return if capacity.nil?
151+
return if in_minutes(hours_already_logged_on_day + model.hours) <= capacity
152+
153+
errors.add :hours, :exceeds_user_working_hours, limit: format_hours(capacity / 60.0)
154+
end
155+
156+
def user_capacity_in_minutes_on(date)
157+
model.user.working_hours.valid_for_date(date)&.effective_minutes_on(date)
158+
end
159+
160+
def in_minutes(hours)
161+
(hours * 60).round
162+
end
163+
164+
def format_hours(hours)
165+
ActiveSupport::NumberHelper.number_to_rounded(hours, precision: 2, strip_insignificant_zeros: true)
166+
end
167+
168+
def validate_spent_on_is_working_day
169+
return unless TimeEntry.prohibit_logging_on_non_working_days?
170+
return if model.spent_on.nil? || model.user.nil?
171+
return unless globally_non_working?(model.spent_on) || personally_non_working?(model.spent_on)
172+
173+
errors.add :spent_on, :not_a_working_day
174+
end
175+
176+
def globally_non_working?(date)
177+
WorkPackages::Shared::WorkingDays.new.non_working?(date)
178+
end
179+
180+
def personally_non_working?(date)
181+
model.user.non_working_times.overlapping(date..date).exists?
111182
end
112183

113184
def validate_project_is_set

modules/costs/app/contracts/time_entries/delete_contract.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828

2929
module TimeEntries
3030
class DeleteContract < ::DeleteContract
31+
include PastMonthRestriction
32+
3133
delete_permission -> {
3234
edit_all = user.allowed_in_project?(:edit_time_entries, model.project)
3335
edit_own = if model.entity.is_a?(WorkPackage)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
module TimeEntries
32+
module PastMonthRestriction
33+
extend ActiveSupport::Concern
34+
35+
included do
36+
validate :validate_spent_on_not_in_past_month
37+
end
38+
39+
private
40+
41+
def validate_spent_on_not_in_past_month
42+
return unless TimeEntry.prohibit_logging_for_past_months?
43+
return unless restricted_spent_on_dates.any? { it < earliest_open_date }
44+
45+
errors.add :spent_on, :in_past_month, date: I18n.l(earliest_open_date)
46+
end
47+
48+
# The persisted date is checked alongside the assigned one, so that an entry belonging
49+
# to a closed month cannot be pulled out of it by moving it into an open one.
50+
def restricted_spent_on_dates
51+
[model.spent_on, model.spent_on_was].compact
52+
end
53+
54+
# Months are closed as a whole, so the grace period opens every month that the date
55+
# it reaches back to belongs to. Without grace this is the start of the current month.
56+
def earliest_open_date
57+
(Time.zone.today - TimeEntry.past_month_grace_days).beginning_of_month
58+
end
59+
end
60+
end

modules/costs/app/models/time_entry.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,40 @@ def must_track_start_and_end_time?
211211
can_track_start_and_end_time? &&
212212
Setting.enforce_tracking_start_and_end_times?
213213
end
214+
215+
def max_hours_per_entry
216+
return nil unless EnterpriseToken.allows_to?(:time_entry_time_restrictions)
217+
218+
limit = Setting.time_entries_max_hours_per_entry
219+
limit if limit.positive?
220+
end
221+
222+
def max_hours_per_day
223+
return nil unless EnterpriseToken.allows_to?(:time_entry_time_restrictions)
224+
225+
limit = Setting.time_entries_max_hours_per_day
226+
limit if limit.positive?
227+
end
228+
229+
def prohibit_logging_on_non_working_days?
230+
EnterpriseToken.allows_to?(:time_entry_time_restrictions) &&
231+
Setting.time_entries_prohibit_logging_on_non_working_days?
232+
end
233+
234+
def limit_to_user_working_hours?
235+
EnterpriseToken.allows_to?(:time_entry_time_restrictions) &&
236+
Setting.time_entries_limit_to_user_working_hours?
237+
end
238+
239+
def prohibit_logging_for_past_months?
240+
EnterpriseToken.allows_to?(:time_entry_time_restrictions) &&
241+
Setting.time_entries_prohibit_logging_for_past_months?
242+
end
243+
244+
# Only meaningful while prohibit_logging_for_past_months? applies
245+
def past_month_grace_days
246+
Setting.time_entries_past_month_grace_days
247+
end
214248
end
215249

216250
private

modules/costs/app/views/admin/time_settings/show.html.erb

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,22 @@ See COPYRIGHT and LICENSE files for more details.
4444
)
4545
%>
4646

47+
<%= render(Primer::Beta::Subhead.new) do |subhead|
48+
subhead.with_heading(tag: :h3, size: :medium) { I18n.t(:label_start_and_finished) }
49+
subhead.with_description { I18n.t(:description_time_settings) }
50+
end %>
51+
4752
<%=
48-
settings_primer_form_with(scope: :settings, action: :update, method: :patch) do |time_form|
53+
settings_primer_form_with(
54+
scope: :settings,
55+
action: :update,
56+
method: :patch,
57+
data: {
58+
controller: "disable-when-checked",
59+
disable_when_checked_reversed_value: true
60+
}
61+
) do |time_form|
4962
render_inline_settings_form(time_form) do |form|
50-
form.html_content do
51-
render(Primer::Beta::Subhead.new(hide_border: true)) do |subhead|
52-
subhead.with_heading(tag: :h3, size: :medium) { I18n.t(:label_mandatory_fields) }
53-
subhead.with_description { I18n.t(:description_time_settings) }
54-
end
55-
end
56-
5763
form.check_box(
5864
name: :allow_tracking_start_and_end_times,
5965
caption: I18n.t(:setting_allow_tracking_start_and_end_times_caption)
@@ -64,7 +70,82 @@ See COPYRIGHT and LICENSE files for more details.
6470
disabled: !EnterpriseToken.allows_to?(:time_entry_time_restrictions)
6571
)
6672

67-
form.submit
73+
form.submit(name: :submit, label: I18n.t(:button_update_start_finish_times), scheme: :default)
74+
end
75+
end
76+
%>
77+
78+
<%= render(Primer::Beta::Subhead.new(mt: 3)) do |subhead|
79+
subhead.with_heading(tag: :h3, size: :medium) { I18n.t(:label_time_entry_restrictions) }
80+
subhead.with_description { I18n.t(:description_time_entry_restrictions) }
81+
end %>
82+
83+
<%=
84+
settings_primer_form_with(
85+
scope: :settings,
86+
action: :update,
87+
method: :patch,
88+
data: {
89+
controller: "disable-when-checked",
90+
disable_when_checked_reversed_value: true
91+
}
92+
) do |time_form|
93+
render_inline_settings_form(time_form) do |form|
94+
form.text_field(
95+
name: :time_entries_max_hours_per_entry,
96+
type: :number,
97+
min: 0,
98+
input_width: :small,
99+
caption: I18n.t(:setting_time_entries_max_hours_per_entry_caption),
100+
disabled: !EnterpriseToken.allows_to?(:time_entry_time_restrictions)
101+
)
102+
103+
form.text_field(
104+
name: :time_entries_max_hours_per_day,
105+
type: :number,
106+
min: 0,
107+
input_width: :small,
108+
caption: I18n.t(:setting_time_entries_max_hours_per_day_caption),
109+
disabled: !EnterpriseToken.allows_to?(:time_entry_time_restrictions)
110+
)
111+
112+
form.check_box(
113+
name: :time_entries_prohibit_logging_on_non_working_days,
114+
caption: I18n.t(:setting_time_entries_prohibit_logging_on_non_working_days_caption),
115+
disabled: !EnterpriseToken.allows_to?(:time_entry_time_restrictions)
116+
)
117+
118+
form.check_box(
119+
name: :time_entries_limit_to_user_working_hours,
120+
caption: I18n.t(:setting_time_entries_limit_to_user_working_hours_caption),
121+
disabled: !EnterpriseToken.allows_to?(:time_entry_time_restrictions)
122+
)
123+
124+
form.check_box(
125+
name: :time_entries_prohibit_logging_for_past_months,
126+
caption: I18n.t(:setting_time_entries_prohibit_logging_for_past_months_caption),
127+
disabled: !EnterpriseToken.allows_to?(:time_entry_time_restrictions),
128+
data: {
129+
target_name: "time_entries_prohibit_logging_for_past_months",
130+
disable_when_checked_target: "cause"
131+
}
132+
)
133+
134+
form.text_field(
135+
name: :time_entries_past_month_grace_days,
136+
type: :number,
137+
min: 0,
138+
input_width: :small,
139+
caption: I18n.t(:setting_time_entries_past_month_grace_days_caption),
140+
disabled: !EnterpriseToken.allows_to?(:time_entry_time_restrictions) ||
141+
!Setting.time_entries_prohibit_logging_for_past_months?,
142+
data: {
143+
target_name: "time_entries_prohibit_logging_for_past_months",
144+
disable_when_checked_target: "effect"
145+
}
146+
)
147+
148+
form.submit(name: :submit, label: I18n.t(:button_update_limits), scheme: :default)
68149
end
69150
end
70151
%>

0 commit comments

Comments
 (0)