From b71355f10b247f35a32fc002d52a9ca536537998 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Lang Date: Mon, 29 Oct 2012 10:06:30 +0000 Subject: [PATCH] Ignore non-working days when rescheduling an issue (#2161). Weekly non-working days can be configured in application settings (set to saturday and sunday by default). git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@10747 e93f8b46-1217-0410-a6f0-8f06a7374b81 --- app/helpers/settings_helper.rb | 2 +- app/models/issue.rb | 25 +++++- app/models/issue_relation.rb | 2 +- app/views/settings/_issues.html.erb | 2 + config/locales/en.yml | 1 + config/locales/fr.yml | 1 + config/settings.yml | 5 ++ lib/redmine/utils.rb | 63 ++++++++++++++ public/stylesheets/application.css | 1 + test/unit/issue_nested_set_test.rb | 2 +- test/unit/issue_test.rb | 83 ++++++++++++++++--- .../lib/redmine/utils/date_calculation.rb | 76 +++++++++++++++++ 12 files changed, 244 insertions(+), 19 deletions(-) create mode 100644 test/unit/lib/redmine/utils/date_calculation.rb diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index c1fa8ea64..14708344d 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -56,7 +56,7 @@ module SettingsHelper Setting.send(setting).include?(value), :id => nil ) + text.to_s, - :class => 'block' + :class => (options[:inline] ? 'inline' : 'block') ) end.join.html_safe end diff --git a/app/models/issue.rb b/app/models/issue.rb index a2ba73d04..119d688c7 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -17,6 +17,7 @@ class Issue < ActiveRecord::Base include Redmine::SafeAttributes + include Redmine::Utils::DateCalculation belongs_to :project belongs_to :tracker @@ -863,6 +864,11 @@ class Issue < ActiveRecord::Base (start_date && due_date) ? due_date - start_date : 0 end + # Returns the duration in working days + def working_duration + (start_date && due_date) ? working_days(start_date, due_date) : 0 + end + def soonest_start @soonest_start ||= ( relations_to.collect{|relation| relation.successor_soonest_start} + @@ -870,22 +876,33 @@ class Issue < ActiveRecord::Base ).compact.max end - def reschedule_after(date) + # Sets start_date on the given date or the next working day + # and changes due_date to keep the same working duration. + def reschedule_on(date) + wd = working_duration + date = next_working_date(date) + self.start_date = date + self.due_date = add_working_days(date, wd) + end + + # Reschedules the issue on the given date or the next working day and saves the record. + # If the issue is a parent task, this is done by rescheduling its subtasks. + def reschedule_on!(date) return if date.nil? if leaf? if start_date.nil? || start_date < date - self.start_date, self.due_date = date, date + duration + reschedule_on(date) begin save rescue ActiveRecord::StaleObjectError reload - self.start_date, self.due_date = date, date + duration + reschedule_on(date) save end end else leaves.each do |leaf| - leaf.reschedule_after(date) + leaf.reschedule_on!(date) end end end diff --git a/app/models/issue_relation.rb b/app/models/issue_relation.rb index 285c4e306..59b1900f0 100644 --- a/app/models/issue_relation.rb +++ b/app/models/issue_relation.rb @@ -135,7 +135,7 @@ class IssueRelation < ActiveRecord::Base def set_issue_to_dates soonest_start = self.successor_soonest_start if soonest_start && issue_to - issue_to.reschedule_after(soonest_start) + issue_to.reschedule_on!(soonest_start) end end diff --git a/app/views/settings/_issues.html.erb b/app/views/settings/_issues.html.erb index a8d004daa..dcf5df86e 100644 --- a/app/views/settings/_issues.html.erb +++ b/app/views/settings/_issues.html.erb @@ -13,6 +13,8 @@

<%= setting_select :issue_done_ratio, Issue::DONE_RATIO_OPTIONS.collect {|i| [l("setting_issue_done_ratio_#{i}"), i]} %>

+

<%= setting_multiselect :non_working_week_days, (1..7).map {|d| [day_name(d), d]}, :inline => true %>

+

<%= setting_text_field :issues_export_limit, :size => 6 %>

<%= setting_text_field :gantt_items_limit, :size => 6 %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index c223b0f88..23a9abd9f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -399,6 +399,7 @@ en: setting_session_timeout: Session inactivity timeout setting_thumbnails_enabled: Display attachment thumbnails setting_thumbnails_size: Thumbnails size (in pixels) + setting_non_working_week_days: Non-working days permission_add_project: Create project permission_add_subprojects: Create subprojects diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 7baf293e7..eff6caeed 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -395,6 +395,7 @@ fr: setting_session_timeout: Durée maximale d'inactivité setting_thumbnails_enabled: Afficher les vignettes des images setting_thumbnails_size: Taille des vignettes (en pixels) + setting_non_working_week_days: Jours non travaillés permission_add_project: Créer un projet permission_add_subprojects: Créer des sous-projets diff --git a/config/settings.yml b/config/settings.yml index 6aa224166..130e26346 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -220,3 +220,8 @@ thumbnails_enabled: thumbnails_size: format: int default: 100 +non_working_week_days: + serialized: true + default: + - 6 + - 7 diff --git a/lib/redmine/utils.rb b/lib/redmine/utils.rb index cfdb4d15d..b68c53470 100644 --- a/lib/redmine/utils.rb +++ b/lib/redmine/utils.rb @@ -51,5 +51,68 @@ module Redmine end end end + + module DateCalculation + # Returns the number of working days between from and to + def working_days(from, to) + days = (to - from).to_i + if days > 0 + weeks = days / 7 + result = weeks * (7 - non_working_week_days.size) + days_left = days - weeks * 7 + start_cwday = from.cwday + days_left.times do |i| + unless non_working_week_days.include?(((start_cwday + i - 1) % 7) + 1) + result += 1 + end + end + result + else + 0 + end + end + + # Adds working days to the given date + def add_working_days(date, working_days) + if working_days > 0 + weeks = working_days / (7 - non_working_week_days.size) + result = weeks * 7 + days_left = working_days - weeks * (7 - non_working_week_days.size) + cwday = date.cwday + while days_left > 0 + cwday += 1 + unless non_working_week_days.include?(((cwday - 1) % 7) + 1) + days_left -= 1 + end + result += 1 + end + next_working_date(date + result) + else + date + end + end + + # Returns the date of the first day on or after the given date that is a working day + def next_working_date(date) + cwday = date.cwday + days = 0 + while non_working_week_days.include?(((cwday + days - 1) % 7) + 1) + days += 1 + end + date + days + end + + # Returns the index of non working week days (1=monday, 7=sunday) + def non_working_week_days + @non_working_week_days ||= begin + days = Setting.non_working_week_days + if days.is_a?(Array) && days.size < 7 + days.map(&:to_i) + else + [] + end + end + end + end end end diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index 9bfc09624..3e5ead969 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -493,6 +493,7 @@ html>body .tabular p {overflow:hidden;} } .tabular label.inline{ + font-weight: normal; float:none; margin-left: 5px !important; width: auto; diff --git a/test/unit/issue_nested_set_test.rb b/test/unit/issue_nested_set_test.rb index c5513f922..d796edf94 100644 --- a/test/unit/issue_nested_set_test.rb +++ b/test/unit/issue_nested_set_test.rb @@ -346,7 +346,7 @@ class IssueNestedSetTest < ActiveSupport::TestCase c1 = Issue.generate!(:start_date => '2010-05-12', :due_date => '2010-05-18', :parent_issue_id => parent.id) c2 = Issue.generate!(:start_date => '2010-06-03', :due_date => '2010-06-10', :parent_issue_id => parent.id) parent.reload - parent.reschedule_after(Date.parse('2010-06-02')) + parent.reschedule_on!(Date.parse('2010-06-02')) c1.reload assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-08')], [c1.start_date, c1.due_date] c2.reload diff --git a/test/unit/issue_test.rb b/test/unit/issue_test.rb index 15e79bec4..1be4c02b6 100644 --- a/test/unit/issue_test.rb +++ b/test/unit/issue_test.rb @@ -1300,22 +1300,81 @@ class IssueTest < ActiveSupport::TestCase assert !closed_statuses.empty? end + def test_reschedule_an_issue_without_dates + with_settings :non_working_week_days => [] do + issue = Issue.new(:start_date => nil, :due_date => nil) + issue.reschedule_on '2012-10-09'.to_date + assert_equal '2012-10-09'.to_date, issue.start_date + assert_equal '2012-10-09'.to_date, issue.due_date + end + + with_settings :non_working_week_days => %w(6 7) do + issue = Issue.new(:start_date => nil, :due_date => nil) + issue.reschedule_on '2012-10-09'.to_date + assert_equal '2012-10-09'.to_date, issue.start_date + assert_equal '2012-10-09'.to_date, issue.due_date + + issue = Issue.new(:start_date => nil, :due_date => nil) + issue.reschedule_on '2012-10-13'.to_date + assert_equal '2012-10-15'.to_date, issue.start_date + assert_equal '2012-10-15'.to_date, issue.due_date + end + end + + def test_reschedule_an_issue_with_start_date + with_settings :non_working_week_days => [] do + issue = Issue.new(:start_date => '2012-10-09', :due_date => nil) + issue.reschedule_on '2012-10-13'.to_date + assert_equal '2012-10-13'.to_date, issue.start_date + assert_equal '2012-10-13'.to_date, issue.due_date + end + + with_settings :non_working_week_days => %w(6 7) do + issue = Issue.new(:start_date => '2012-10-09', :due_date => nil) + issue.reschedule_on '2012-10-11'.to_date + assert_equal '2012-10-11'.to_date, issue.start_date + assert_equal '2012-10-11'.to_date, issue.due_date + + issue = Issue.new(:start_date => '2012-10-09', :due_date => nil) + issue.reschedule_on '2012-10-13'.to_date + assert_equal '2012-10-15'.to_date, issue.start_date + assert_equal '2012-10-15'.to_date, issue.due_date + end + end + + def test_reschedule_an_issue_with_start_and_due_dates + with_settings :non_working_week_days => [] do + issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-15') + issue.reschedule_on '2012-10-13'.to_date + assert_equal '2012-10-13'.to_date, issue.start_date + assert_equal '2012-10-19'.to_date, issue.due_date + end + + with_settings :non_working_week_days => %w(6 7) do + issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-19') # 8 working days + issue.reschedule_on '2012-10-11'.to_date + assert_equal '2012-10-11'.to_date, issue.start_date + assert_equal '2012-10-23'.to_date, issue.due_date + + issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-19') + issue.reschedule_on '2012-10-13'.to_date + assert_equal '2012-10-15'.to_date, issue.start_date + assert_equal '2012-10-25'.to_date, issue.due_date + end + end + def test_rescheduling_an_issue_should_reschedule_following_issue - issue1 = Issue.create!(:project_id => 1, :tracker_id => 1, - :author_id => 1, :status_id => 1, - :subject => '-', - :start_date => Date.today, :due_date => Date.today + 2) - issue2 = Issue.create!(:project_id => 1, :tracker_id => 1, - :author_id => 1, :status_id => 1, - :subject => '-', - :start_date => Date.today, :due_date => Date.today + 2) + issue1 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17') + issue2 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17') IssueRelation.create!(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_PRECEDES) - assert_equal issue1.due_date + 1, issue2.reload.start_date + assert_equal Date.parse('2012-10-18'), issue2.reload.start_date - issue1.due_date = Date.today + 5 + issue1.due_date = '2012-10-23' issue1.save! - assert_equal issue1.due_date + 1, issue2.reload.start_date + issue2.reload + assert_equal Date.parse('2012-10-24'), issue2.start_date + assert_equal Date.parse('2012-10-26'), issue2.due_date end def test_rescheduling_a_stale_issue_should_not_raise_an_error @@ -1326,7 +1385,7 @@ class IssueTest < ActiveSupport::TestCase date = 10.days.from_now.to_date assert_nothing_raised do - stale.reschedule_after(date) + stale.reschedule_on!(date) end assert_equal date, stale.reload.start_date end diff --git a/test/unit/lib/redmine/utils/date_calculation.rb b/test/unit/lib/redmine/utils/date_calculation.rb new file mode 100644 index 000000000..6cd904969 --- /dev/null +++ b/test/unit/lib/redmine/utils/date_calculation.rb @@ -0,0 +1,76 @@ +# Redmine - project management software +# Copyright (C) 2006-2012 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require File.expand_path('../../../../../test_helper', __FILE__) + +class Redmine::Utils::DateCalculationTest < ActiveSupport::TestCase + include Redmine::Utils::DateCalculation + + def test_working_days_without_non_working_week_days + with_settings :non_working_week_days => [] do + assert_working_days 18, '2012-10-09', '2012-10-27' + assert_working_days 6, '2012-10-09', '2012-10-15' + assert_working_days 5, '2012-10-09', '2012-10-14' + assert_working_days 3, '2012-10-09', '2012-10-12' + assert_working_days 3, '2012-10-14', '2012-10-17' + assert_working_days 16, '2012-10-14', '2012-10-30' + end + end + + def test_working_days_with_non_working_week_days + with_settings :non_working_week_days => %w(6 7) do + assert_working_days 14, '2012-10-09', '2012-10-27' + assert_working_days 4, '2012-10-09', '2012-10-15' + assert_working_days 4, '2012-10-09', '2012-10-14' + assert_working_days 3, '2012-10-09', '2012-10-12' + assert_working_days 8, '2012-10-09', '2012-10-19' + assert_working_days 8, '2012-10-11', '2012-10-23' + assert_working_days 2, '2012-10-14', '2012-10-17' + assert_working_days 11, '2012-10-14', '2012-10-30' + end + end + + def test_add_working_days_without_non_working_week_days + with_settings :non_working_week_days => [] do + assert_add_working_days '2012-10-10', '2012-10-10', 0 + assert_add_working_days '2012-10-11', '2012-10-10', 1 + assert_add_working_days '2012-10-12', '2012-10-10', 2 + assert_add_working_days '2012-10-13', '2012-10-10', 3 + assert_add_working_days '2012-10-25', '2012-10-10', 15 + end + end + + def test_add_working_days_with_non_working_week_days + with_settings :non_working_week_days => %w(6 7) do + assert_add_working_days '2012-10-10', '2012-10-10', 0 + assert_add_working_days '2012-10-11', '2012-10-10', 1 + assert_add_working_days '2012-10-12', '2012-10-10', 2 + assert_add_working_days '2012-10-15', '2012-10-10', 3 + assert_add_working_days '2012-10-31', '2012-10-10', 15 + assert_add_working_days '2012-10-19', '2012-10-09', 8 + assert_add_working_days '2012-10-23', '2012-10-11', 8 + end + end + + def assert_working_days(expected_days, from, to) + assert_equal expected_days, working_days(from.to_date, to.to_date) + end + + def assert_add_working_days(expected_date, from, working_days) + assert_equal expected_date.to_date, add_working_days(from.to_date, working_days) + end +end