From 72125ce1010695807d1ea6397adda06c24aedd96 Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Mon, 5 Oct 2026 14:12:54 +0900 Subject: [PATCH 1/4] Allow non-working dates such as holidays to be registered --- app/assets/stylesheets/application.css | 3 + app/assets/stylesheets/responsive.css | 4 +- .../non_working_dates_controller.rb | 40 ++++++ .../non_working_dates_controller.rb | 91 ++++++++++++++ app/helpers/gantt_helper.rb | 2 +- app/models/non_working_date.rb | 61 +++++++++ .../context_menus/non_working_dates.html.erb | 12 ++ app/views/non_working_dates/_form.html.erb | 6 + app/views/non_working_dates/edit.html.erb | 6 + app/views/non_working_dates/index.html.erb | 54 ++++++++ app/views/non_working_dates/new.html.erb | 7 ++ app/views/settings/_issues.html.erb | 5 +- config/locales/en.yml | 4 + config/routes.rb | 6 + ...20261005121927_create_non_working_dates.rb | 9 ++ lib/redmine/helpers/calendar.rb | 2 +- lib/redmine/helpers/gantt.rb | 6 +- lib/redmine/utils/date_calculation.rb | 56 ++++++--- test/functional/calendars_controller_test.rb | 15 +++ .../non_working_dates_controller_test.rb | 53 ++++++++ test/functional/gantts_controller_test.rb | 11 ++ .../non_working_dates_controller_test.rb | 119 ++++++++++++++++++ test/helpers/gantt_helper_test.rb | 3 +- test/unit/issue_test.rb | 14 +++ .../redmine/utils/date_calculation_test.rb | 25 ++++ test/unit/non_working_date_test.rb | 34 +++++ 26 files changed, 625 insertions(+), 23 deletions(-) create mode 100644 app/controllers/context_menus/non_working_dates_controller.rb create mode 100644 app/controllers/non_working_dates_controller.rb create mode 100644 app/models/non_working_date.rb create mode 100644 app/views/context_menus/non_working_dates.html.erb create mode 100644 app/views/non_working_dates/_form.html.erb create mode 100644 app/views/non_working_dates/edit.html.erb create mode 100644 app/views/non_working_dates/index.html.erb create mode 100644 app/views/non_working_dates/new.html.erb create mode 100644 db/migrate/20261005121927_create_non_working_dates.rb create mode 100644 test/functional/context_menus/non_working_dates_controller_test.rb create mode 100644 test/functional/non_working_dates_controller_test.rb create mode 100644 test/unit/non_working_date_test.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 9c37bcf38..e02dace81 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1256,6 +1256,9 @@ textarea#custom_field_default_value {inline-size: 95%; resize: vertical;} input#content_comments {inline-size: 99%} span.pagination {margin-inline-start: 3px; color: var(--oc-gray-6); display: block;} +.non-working-dates-year {display: block; margin-block-end: 1em;} +.non-working-dates-year span.pagination {display: inline-block;} +.non-working-dates-year .pagination ul.pages li {margin-block-end: 0;} .pagination ul.pages { margin-block: 0; margin-inline: 0 5px; diff --git a/app/assets/stylesheets/responsive.css b/app/assets/stylesheets/responsive.css index baa3854e3..fc9727617 100644 --- a/app/assets/stylesheets/responsive.css +++ b/app/assets/stylesheets/responsive.css @@ -879,7 +879,9 @@ } div.issue .next-prev-links .pagination ul.pages li.previous, div.issue .next-prev-links .pagination ul.pages li.position, - div.issue .next-prev-links .pagination ul.pages li.next { + div.issue .next-prev-links .pagination ul.pages li.next, + .non-working-dates-year .pagination ul.pages li.previous, + .non-working-dates-year .pagination ul.pages li.next { display: inline-block; inline-size: auto; overflow: visible; diff --git a/app/controllers/context_menus/non_working_dates_controller.rb b/app/controllers/context_menus/non_working_dates_controller.rb new file mode 100644 index 000000000..bbf38726a --- /dev/null +++ b/app/controllers/context_menus/non_working_dates_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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. + +module ContextMenus + class NonWorkingDatesController < BaseController + before_action :require_admin + before_action :find_non_working_dates + + def index + render_context_menu 'non_working_dates' + end + + private + + def find_non_working_dates + @non_working_dates = NonWorkingDate.where(id: params[:id] || params[:ids]).to_a + (render_404; return) if @non_working_dates.empty? + + if @non_working_dates.size == 1 + @non_working_date = @non_working_dates.first + end + end + end +end diff --git a/app/controllers/non_working_dates_controller.rb b/app/controllers/non_working_dates_controller.rb new file mode 100644 index 000000000..2b9e35157 --- /dev/null +++ b/app/controllers/non_working_dates_controller.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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. + +class NonWorkingDatesController < ApplicationController + layout 'admin' + self.main_menu = false + menu_item :settings + + before_action :require_admin + before_action :find_non_working_date, :only => [:edit, :update, :destroy] + + def index + @year = params[:year].to_i if /\A\d{4}\z/.match?(params[:year].to_s) + @year ||= User.current.today.year + years = NonWorkingDate.years + @years = (years | [@year, User.current.today.year]).sort + @previous_year = @year - 1 if years.any? {|year| year < @year} + @next_year = @year + 1 if years.any? {|year| year > @year} + @non_working_dates = NonWorkingDate.in_year(@year).sorted.to_a + @non_working_date_count = NonWorkingDate.count + end + + def new + @non_working_date = NonWorkingDate.new + end + + def create + @non_working_date = NonWorkingDate.new + @non_working_date.safe_attributes = params[:non_working_date] + if @non_working_date.save + flash[:notice] = l(:notice_successful_create) + if params[:continue] + redirect_to new_non_working_date_path + else + redirect_to non_working_dates_path(:year => @non_working_date.date.year) + end + else + render :action => 'new' + end + end + + def edit + end + + def update + @non_working_date.safe_attributes = params[:non_working_date] + if @non_working_date.save + flash[:notice] = l(:notice_successful_update) + redirect_to non_working_dates_path(:year => @non_working_date.date.year) + else + render :action => 'edit' + end + end + + def destroy + @non_working_date.destroy + flash[:notice] = l(:notice_successful_delete) + redirect_to non_working_dates_path(:year => @non_working_date.date.year) + end + + def bulk_destroy + NonWorkingDate.where(:id => params[:ids]).delete_all + NonWorkingDate.clear_dates_cache + flash[:notice] = l(:notice_successful_delete) + redirect_back_or_default non_working_dates_path + end + + private + + def find_non_working_date + @non_working_date = NonWorkingDate.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end +end diff --git a/app/helpers/gantt_helper.rb b/app/helpers/gantt_helper.rb index 88800aa76..31fb6b1bb 100644 --- a/app/helpers/gantt_helper.rb +++ b/app/helpers/gantt_helper.rb @@ -173,7 +173,7 @@ module GanttHelper Period.new( date: date, width: zoom, - non_working: @gantt.non_working_week_days.include?(date.cwday), + non_working: @gantt.non_working_day?(date), spans_body: spans_body, last: date == @gantt.date_to ) diff --git a/app/models/non_working_date.rb b/app/models/non_working_date.rb new file mode 100644 index 000000000..79ee918ee --- /dev/null +++ b/app/models/non_working_date.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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. + +class NonWorkingDate < ApplicationRecord + include Redmine::SafeAttributes + + class Current < ActiveSupport::CurrentAttributes + attribute :sorted_dates, :dates + end + + after_save {self.class.clear_dates_cache} + after_destroy {self.class.clear_dates_cache} + after_rollback {self.class.clear_dates_cache} + + validates_presence_of :date + validates_uniqueness_of :date + validates :date, :date => true + validates_length_of :name, :maximum => 255 + + scope :sorted, lambda {order(:date)} + scope :in_year, lambda {|year| where(:date => Date.new(year, 1, 1)..Date.new(year, 12, 31))} + + safe_attributes 'date', 'name' + + # Returns the registered dates as a sorted Array. + # The result is cached for the duration of the current request. + def self.sorted_dates + Current.sorted_dates ||= order(:date).pluck(:date) + end + + # Returns the registered dates as a Set. + # The result is cached for the duration of the current request. + def self.dates + Current.dates ||= sorted_dates.to_set + end + + def self.clear_dates_cache + Current.reset + end + + # Returns the years that have at least one non-working date + def self.years + pluck(:date).map(&:year).uniq.sort + end +end diff --git a/app/views/context_menus/non_working_dates.html.erb b/app/views/context_menus/non_working_dates.html.erb new file mode 100644 index 000000000..2b11cf1e0 --- /dev/null +++ b/app/views/context_menus/non_working_dates.html.erb @@ -0,0 +1,12 @@ + diff --git a/app/views/non_working_dates/_form.html.erb b/app/views/non_working_dates/_form.html.erb new file mode 100644 index 000000000..a1e699461 --- /dev/null +++ b/app/views/non_working_dates/_form.html.erb @@ -0,0 +1,6 @@ +<%= error_messages_for 'non_working_date' %> + +
+

<%= f.date_field :date, :size => 10, :required => true %><%= calendar_for('non_working_date_date') %>

+

<%= f.text_field :name, :size => 40 %>

+
diff --git a/app/views/non_working_dates/edit.html.erb b/app/views/non_working_dates/edit.html.erb new file mode 100644 index 000000000..ae95cb16e --- /dev/null +++ b/app/views/non_working_dates/edit.html.erb @@ -0,0 +1,6 @@ +<%= title [l(:label_non_working_date_plural), non_working_dates_path(:year => @non_working_date.date_was.year)], format_date(@non_working_date.date_was) %> + +<%= labelled_form_for @non_working_date do |f| %> + <%= render :partial => 'form', :locals => {:f => f} %> + <%= submit_tag l(:button_save) %> +<% end %> diff --git a/app/views/non_working_dates/index.html.erb b/app/views/non_working_dates/index.html.erb new file mode 100644 index 000000000..ee4bd9998 --- /dev/null +++ b/app/views/non_working_dates/index.html.erb @@ -0,0 +1,54 @@ +
+<%= link_to(sprite_icon('add', l(:label_non_working_date_new)), new_non_working_date_path, :class => 'icon icon-add') %> +
+ +

<%= l(:label_non_working_date_plural) %>

+ +<%= form_tag(non_working_dates_path, :method => :get, :class => 'non-working-dates-year') do %> + <%= label_tag 'year', l(:label_year) %> + <%= select_tag 'year', options_for_select(@years, @year), :onchange => 'this.form.submit();' %> + + + +<% end %> + +<% if @non_working_dates.any? %> +<%= form_tag({}, :data => {:cm_url => non_working_dates_context_menu_path}) do -%> +<%= hidden_field_tag 'back_url', url_for(:params => request.query_parameters), :id => nil %> + + + + + + + + +<% @non_working_dates.each do |non_working_date| %> + "> + + + + + +<% end %> + +
+ <%= check_box_tag 'check_all', '', false, :class => 'toggle-selection', + :title => "#{l(:button_check_all)} / #{l(:button_uncheck_all)}" %> + <%= l(:field_date) %><%= l(:field_name) %>
<%= check_box_tag("ids[]", non_working_date.id, false, :id => nil) %><%= link_to "#{format_date(non_working_date.date)} (#{abbr_day_name(non_working_date.date.cwday)})", edit_non_working_date_path(non_working_date) %><%= non_working_date.name %> + <%= link_to_context_menu %> +
+<% end -%> +(<%= @non_working_dates.size %>/<%= @non_working_date_count %>) +<%= context_menu %> +<% else %> +

<%= l(:label_no_data) %>

+<% end %> + +<% html_title(l(:label_non_working_date_plural)) -%> diff --git a/app/views/non_working_dates/new.html.erb b/app/views/non_working_dates/new.html.erb new file mode 100644 index 000000000..8b63c943c --- /dev/null +++ b/app/views/non_working_dates/new.html.erb @@ -0,0 +1,7 @@ +<%= title [l(:label_non_working_date_plural), non_working_dates_path], l(:label_non_working_date_new) %> + +<%= labelled_form_for @non_working_date do |f| %> + <%= render :partial => 'form', :locals => {:f => f} %> + <%= submit_tag l(:button_create) %> + <%= submit_tag l(:button_create_and_continue), :name => 'continue' %> +<% end %> diff --git a/app/views/settings/_issues.html.erb b/app/views/settings/_issues.html.erb index b9acf4fda..6be816304 100644 --- a/app/views/settings/_issues.html.erb +++ b/app/views/settings/_issues.html.erb @@ -50,7 +50,10 @@

<%= setting_select :issue_done_ratio_interval, [5, 10].collect {|i| ["#{i} %", i]} %>

-

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

+

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

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

diff --git a/config/locales/en.yml b/config/locales/en.yml index 41ba8645e..56db8c32c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -285,6 +285,7 @@ en: mail_destroy_project_with_subprojects_successful: Project %{value} and its subprojects were deleted successfully. + field_date: Date field_name: Name field_description: Description field_summary: Summary @@ -690,6 +691,8 @@ en: label_custom_field_new: New custom field label_enumerations: Enumerations label_enumeration_new: New value + label_non_working_date_plural: Non-working dates + label_non_working_date_new: New non-working date label_information: Information label_information_plural: Information label_register: Register @@ -1440,6 +1443,7 @@ en: description_wiki_subpages_reassign: Choose new parent page text_repository_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.
Once saved, the identifier cannot be changed.' text_login_required_html: When not requiring authentication, public projects and their contents are openly available on the network. You can edit the applicable permissions. + text_non_working_dates_html: 'You can add non-working dates such as holidays: Manage non-working dates' label_login_required_yes: "Yes" label_login_required_no: "No, allow anonymous access to public projects" text_project_is_public_non_member: Public projects and their contents are available to all logged-in users. diff --git a/config/routes.rb b/config/routes.rb index 34bae573c..f62b94654 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -371,6 +371,12 @@ Rails.application.routes.draw do end resources :enumerations, :except => :show match 'enumerations/:type', :to => 'enumerations#index', :via => :get + match '/non_working_dates/context_menu', :to => 'context_menus/non_working_dates#index', :as => :non_working_dates_context_menu, :via => [:get, :post] + resources :non_working_dates, :except => :show do + collection do + delete 'bulk_destroy' + end + end get '(projects/:id)/search', :controller => 'search', :action => 'index', :as => 'search' diff --git a/db/migrate/20261005121927_create_non_working_dates.rb b/db/migrate/20261005121927_create_non_working_dates.rb new file mode 100644 index 000000000..6a82d5810 --- /dev/null +++ b/db/migrate/20261005121927_create_non_working_dates.rb @@ -0,0 +1,9 @@ +class CreateNonWorkingDates < ActiveRecord::Migration[8.1] + def change + create_table :non_working_dates do |t| + t.date :date, :null => false + t.string :name + end + add_index :non_working_dates, :date, :unique => true + end +end diff --git a/lib/redmine/helpers/calendar.rb b/lib/redmine/helpers/calendar.rb index 05887ed34..9268e99a9 100644 --- a/lib/redmine/helpers/calendar.rb +++ b/lib/redmine/helpers/calendar.rb @@ -59,7 +59,7 @@ module Redmine def day_css_classes(day) css = day.month==month ? +'this-month' : +'other-month' css << " today" if User.current.today == day - css << " nwday" if non_working_week_days.include?(day.cwday) + css << " nwday" if non_working_day?(day) css end diff --git a/lib/redmine/helpers/gantt.rb b/lib/redmine/helpers/gantt.rb index 46a2cc289..5b2cdf1ce 100644 --- a/lib/redmine/helpers/gantt.rb +++ b/lib/redmine/helpers/gantt.rb @@ -549,7 +549,7 @@ module Redmine height = g_height + header_height - 1 (@date_from..date_to).each do |g_date| width = zoom - gc.fill(non_working_week_days.include?(g_date.cwday) ? '#eee' : 'white') + gc.fill(non_working_day?(g_date) ? '#eee' : 'white') gc.stroke('#ddd') gc.strokewidth(1) gc.draw('rectangle %d,%d %d,%d' % [ @@ -669,7 +669,7 @@ module Redmine width = zoom pdf.SetY(y_start + header_height * 2) pdf.SetX(left) - pdf.SetTextColor(non_working_week_days.include?(g_date.cwday) ? 150 : 0) + pdf.SetTextColor(non_working_day?(g_date) ? 150 : 0) pdf.RDMCell(width, height, day_num.day.to_s, "LTR", 0, "C") left = left + width day_num = day_num + 1 @@ -684,7 +684,7 @@ module Redmine width = zoom pdf.SetY(y_start + header_height * (show_day_num ? 3 : 2)) pdf.SetX(left) - pdf.SetTextColor(non_working_week_days.include?(g_date.cwday) ? 150 : 0) + pdf.SetTextColor(non_working_day?(g_date) ? 150 : 0) pdf.RDMCell(width, height, day_name(g_date.cwday).first, "LTR", 0, "C") left = left + width end diff --git a/lib/redmine/utils/date_calculation.rb b/lib/redmine/utils/date_calculation.rb index 6a2a5201c..4c375e7ae 100644 --- a/lib/redmine/utils/date_calculation.rb +++ b/lib/redmine/utils/date_calculation.rb @@ -33,7 +33,7 @@ module Redmine result += 1 end end - result + result - non_working_dates_count(from, to) else 0 end @@ -42,18 +42,15 @@ module Redmine # 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 + result = add_working_week_days(date, working_days) + # Each registered non working date that was passed over + # pushes the result back by one more working day + from = date + while (skipped = non_working_dates_count(from + 1, result + 1)) > 0 + from = result + result = add_working_week_days(result, skipped) end - next_working_date(date + result) + next_working_date(result) else date end @@ -61,10 +58,39 @@ module Redmine # Returns the date of the first day on or after the given date that is a working day def next_working_date(date) + date += 1 while non_working_day?(date) + date + end + + # Returns true if the given date is a non working day + def non_working_day?(date) + non_working_week_days.include?(date.cwday) || NonWorkingDate.dates.include?(date) + end + + # Adds working days to the given date, + # taking only non working week days into account + def add_working_week_days(date, working_days) + 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 - days = 0 - days += 1 while non_working_week_days.include?(((cwday + days - 1) % 7) + 1) - date + days + while days_left > 0 + cwday += 1 + unless non_working_week_days.include?(((cwday - 1) % 7) + 1) + days_left -= 1 + end + result += 1 + end + date + result + end + + # Returns the number of registered non working dates that are on or after from + # and before to, and that do not fall on a non working week day + def non_working_dates_count(from, to) + dates = NonWorkingDate.sorted_dates + first = dates.bsearch_index {|date| date >= from} || dates.size + last = dates.bsearch_index {|date| date >= to} || dates.size + dates[first...last].count {|date| non_working_week_days.exclude?(date.cwday)} end # Returns the index of non working week days (1=monday, 7=sunday) diff --git a/test/functional/calendars_controller_test.rb b/test/functional/calendars_controller_test.rb index 0410bb54a..e9353c991 100644 --- a/test/functional/calendars_controller_test.rb +++ b/test/functional/calendars_controller_test.rb @@ -277,4 +277,19 @@ class CalendarsControllerTest < Redmine::ControllerTest assert_select 'li.nwday', :text => /^10/ end end + + def test_show_calendar_should_mark_non_working_dates + NonWorkingDate.create!(:date => '2016-12-29') + get( + :show, + :params => { + :month => '12', + :year => '2016' + } + ) + assert_response :success + + assert_select 'li.nwday', 11 + assert_select 'li.nwday', :text => /^29/ + end end diff --git a/test/functional/context_menus/non_working_dates_controller_test.rb b/test/functional/context_menus/non_working_dates_controller_test.rb new file mode 100644 index 000000000..3b331194b --- /dev/null +++ b/test/functional/context_menus/non_working_dates_controller_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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_relative '../../test_helper' + +module ContextMenus + class NonWorkingDatesControllerTest < Redmine::ControllerTest + def setup + User.current = nil + @request.session[:user_id] = 1 # admin + end + + def test_context_menu_with_one_non_working_date + non_working_date = NonWorkingDate.create!(:date => '2026-06-25') + + get :index, :params => {:ids => [non_working_date.id]} + assert_response :success + assert_select 'a.icon-edit[href=?]', "/non_working_dates/#{non_working_date.id}/edit" + assert_select 'a.icon-del[href$=?]', "/non_working_dates/bulk_destroy?ids%5B%5D=#{non_working_date.id}" + end + + def test_context_menu_with_multiple_non_working_dates + ids = %w(2026-08-13 2026-08-14).map {|date| NonWorkingDate.create!(:date => date).id} + + get :index, :params => {:ids => ids} + assert_response :success + assert_select 'a.icon-edit', 0 + assert_select 'a.icon-del[href$=?]', "/non_working_dates/bulk_destroy?ids%5B%5D=#{ids[0]}&ids%5B%5D=#{ids[1]}" + end + + def test_context_menu_by_non_admin_should_respond_with_403 + @request.session[:user_id] = 2 + get :index, :params => {:ids => [NonWorkingDate.create!(:date => '2026-06-25').id]} + assert_response :forbidden + end + end +end diff --git a/test/functional/gantts_controller_test.rb b/test/functional/gantts_controller_test.rb index ddd128371..619cecfb5 100644 --- a/test/functional/gantts_controller_test.rb +++ b/test/functional/gantts_controller_test.rb @@ -146,6 +146,17 @@ class GanttsControllerTest < Redmine::ControllerTest assert_select 'a', :text => /Private child of eCookbook/, :count => 0 end + def test_gantt_should_mark_non_working_dates + NonWorkingDate.create!(:date => '2016-12-29') + + get :show, :params => {:year => '2016', :month => '12', :months => '1', :zoom => '4'} + assert_response :success + + # 9 week-end days and 1 non-working date, in both the day number and the day name rows + assert_select 'div.gantt-period-non-working', 20 + assert_select 'div.gantt-period-non-working', :text => '29' + end + def test_gantt_should_display_relations IssueRelation.delete_all issue1 = Issue.generate!(:start_date => 1.day.from_now.to_date, :due_date => 3.day.from_now.to_date) diff --git a/test/functional/non_working_dates_controller_test.rb b/test/functional/non_working_dates_controller_test.rb new file mode 100644 index 000000000..75ec2692b --- /dev/null +++ b/test/functional/non_working_dates_controller_test.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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_relative '../test_helper' + +class NonWorkingDatesControllerTest < Redmine::ControllerTest + def setup + User.current = nil + @request.session[:user_id] = 1 # admin + end + + def test_index_should_list_non_working_dates_of_the_current_year_by_default + year = User.find(1).today.year + NonWorkingDate.create!(:date => Date.new(year, 6, 25), :name => 'Company Foundation Day') + NonWorkingDate.create!(:date => Date.new(year - 1, 8, 13), :name => 'Summer Shutdown') + + get :index + assert_response :success + assert_select 'table.non_working_dates tbody tr', 1 + assert_select 'table.non_working_dates td.name', :text => 'Company Foundation Day' + assert_select 'form[data-cm-url=?] input[name=?]', '/non_working_dates/context_menu', 'ids[]', 1 + assert_select 'span.pagination span.items', :text => '(1/2)' + assert_select 'form.non-working-dates-year' do + assert_select 'select[name=year] option', 2 + assert_select 'select[name=year] option[selected=selected]', :text => year.to_s + assert_select 'li.previous a[href=?]', "/non_working_dates?year=#{year - 1}" + assert_select 'li.next span', :text => /Next/ + assert_select 'li.next a', 0 + end + end + + def test_index_with_year + NonWorkingDate.create!(:date => '2020-12-30', :name => 'Year-end Shutdown') + NonWorkingDate.create!(:date => '2021-12-30') + + get :index, :params => {:year => '2020'} + assert_response :success + assert_select 'table.non_working_dates tbody tr', 1 + assert_select 'table.non_working_dates td.name', :text => 'Year-end Shutdown' + end + + def test_index_by_non_admin_should_respond_with_403 + @request.session[:user_id] = 2 + get :index + assert_response :forbidden + end + + def test_create + assert_difference 'NonWorkingDate.count' do + post :create, :params => {:non_working_date => {:date => '2026-06-25', :name => 'Company Foundation Day'}} + end + assert_redirected_to '/non_working_dates?year=2026' + non_working_date = NonWorkingDate.order(:id).last + assert_equal Date.new(2026, 6, 25), non_working_date.date + assert_equal 'Company Foundation Day', non_working_date.name + end + + def test_create_and_continue + assert_difference 'NonWorkingDate.count' do + post :create, :params => {:non_working_date => {:date => '2026-06-25'}, :continue => 'Create and continue'} + end + assert_redirected_to '/non_working_dates/new' + end + + def test_create_with_duplicate_date_should_fail + NonWorkingDate.create!(:date => '2026-06-25') + + assert_no_difference 'NonWorkingDate.count' do + post :create, :params => {:non_working_date => {:date => '2026-06-25'}} + end + assert_response :success + assert_select_error /Date has already been taken/ + end + + def test_update + non_working_date = NonWorkingDate.create!(:date => '2026-08-13', :name => 'Summer Shutdown') + + put :update, :params => {:id => non_working_date.id, :non_working_date => {:date => '2027-08-12', :name => 'Summer Holidays'}} + assert_redirected_to '/non_working_dates?year=2027' + non_working_date.reload + assert_equal Date.new(2027, 8, 12), non_working_date.date + assert_equal 'Summer Holidays', non_working_date.name + end + + def test_destroy + non_working_date = NonWorkingDate.create!(:date => '2026-06-25') + + assert_difference 'NonWorkingDate.count', -1 do + delete :destroy, :params => {:id => non_working_date.id} + end + assert_redirected_to '/non_working_dates?year=2026' + end + + def test_bulk_destroy + first, second, third = %w(2026-08-12 2026-08-13 2026-08-14).map {|date| NonWorkingDate.create!(:date => date)} + + assert_difference 'NonWorkingDate.count', -2 do + delete :bulk_destroy, :params => {:ids => [first.id, third.id], :back_url => '/non_working_dates?year=2026'} + end + assert_redirected_to '/non_working_dates?year=2026' + assert_equal [second], NonWorkingDate.all.to_a + end +end diff --git a/test/helpers/gantt_helper_test.rb b/test/helpers/gantt_helper_test.rb index 9755c9c64..bd56c534c 100644 --- a/test/helpers/gantt_helper_test.rb +++ b/test/helpers/gantt_helper_test.rb @@ -171,8 +171,9 @@ class GanttHelperTest < Redmine::HelperTest months: 1, zoom: 3, number_of_rows: 0, non_working_week_days: [6, 7]) gantt = stub( date_from: date_from, date_to: date_to, months: months, zoom: zoom, - number_of_rows: number_of_rows, non_working_week_days: non_working_week_days + number_of_rows: number_of_rows ) + gantt.define_singleton_method(:non_working_day?) {|date| non_working_week_days.include?(date.cwday)} GanttHelper::ChartLayout.new(gantt) end end diff --git a/test/unit/issue_test.rb b/test/unit/issue_test.rb index 852686473..b22595c75 100644 --- a/test/unit/issue_test.rb +++ b/test/unit/issue_test.rb @@ -2468,6 +2468,20 @@ class IssueTest < ActiveSupport::TestCase end end + def test_rescheduling_an_issue_should_skip_non_working_dates + NonWorkingDate.create!(:date => '2012-10-18') + NonWorkingDate.create!(:date => '2012-10-22') + with_settings :non_working_week_days => %w(6 7) do + 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) + issue2.reload + assert_equal Date.parse('2012-10-19'), issue2.start_date + assert_equal Date.parse('2012-10-24'), issue2.due_date + end + end + def test_rescheduling_an_issue_to_an_earlier_due_date_should_reschedule_following_issue 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') diff --git a/test/unit/lib/redmine/utils/date_calculation_test.rb b/test/unit/lib/redmine/utils/date_calculation_test.rb index ac270f82a..b4e276106 100644 --- a/test/unit/lib/redmine/utils/date_calculation_test.rb +++ b/test/unit/lib/redmine/utils/date_calculation_test.rb @@ -68,6 +68,31 @@ class Redmine::Utils::DateCalculationTest < ActiveSupport::TestCase end end + def test_working_days_with_non_working_dates + NonWorkingDate.create!(:date => '2012-10-10') + # Registered on a non-working week day, should not be counted twice + NonWorkingDate.create!(:date => '2012-10-13') + with_settings :non_working_week_days => %w(6 7) do + assert_working_days 13, '2012-10-09', '2012-10-27' + assert_working_days 1, '2012-10-09', '2012-10-11' + assert_working_days 0, '2012-10-10', '2012-10-11' + assert_working_days 3, '2012-10-11', '2012-10-16' + end + end + + def test_add_working_days_with_non_working_dates + NonWorkingDate.create!(:date => '2012-10-11') + NonWorkingDate.create!(:date => '2012-10-15') + 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-12', '2012-10-10', 1 + assert_add_working_days '2012-10-16', '2012-10-10', 2 + assert_add_working_days '2012-10-17', '2012-10-10', 3 + # From a non-working date + assert_add_working_days '2012-10-16', '2012-10-13', 1 + end + end + def assert_working_days(expected_days, from, to) assert_equal expected_days, working_days(from.to_date, to.to_date) end diff --git a/test/unit/non_working_date_test.rb b/test/unit/non_working_date_test.rb new file mode 100644 index 000000000..c54348d2c --- /dev/null +++ b/test/unit/non_working_date_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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_relative '../test_helper' + +class NonWorkingDateTest < ActiveSupport::TestCase + def test_dates_should_be_cached_and_refreshed_on_change + non_working_date = NonWorkingDate.create!(:date => '2026-08-13') + assert_equal [Date.new(2026, 8, 13)], NonWorkingDate.sorted_dates + assert_no_queries {NonWorkingDate.sorted_dates} + + non_working_date.update!(:date => '2026-08-14') + assert_equal [Date.new(2026, 8, 14)], NonWorkingDate.sorted_dates + + non_working_date.destroy + assert_empty NonWorkingDate.sorted_dates + end +end -- 2.55.0