From 3a8ef0b1b485872845305b64c6f8543d4a681ed8 Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Mon, 5 Oct 2026 14:13:58 +0900 Subject: [PATCH 2/4] Import and export non-working dates as CSV files --- app/assets/stylesheets/application.css | 4 ++ .../non_working_dates_controller.rb | 25 ++++--- app/helpers/non_working_dates_helper.rb | 29 ++++++++ app/models/non_working_date_import.rb | 55 +++++++++++++++ .../_non_working_dates_mapping.html.erb | 11 +++ .../_non_working_dates_saved_objects.html.erb | 17 +++++ app/views/non_working_dates/index.html.erb | 19 ++++++ config/locales/en.yml | 2 + config/routes.rb | 2 + .../files/import_non_working_dates.csv | 4 ++ test/functional/imports_controller_test.rb | 27 ++++++++ .../non_working_dates_controller_test.rb | 13 ++++ test/unit/non_working_date_import_test.rb | 68 +++++++++++++++++++ 13 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 app/helpers/non_working_dates_helper.rb create mode 100644 app/models/non_working_date_import.rb create mode 100644 app/views/imports/_non_working_dates_mapping.html.erb create mode 100644 app/views/imports/_non_working_dates_saved_objects.html.erb create mode 100644 test/fixtures/files/import_non_working_dates.csv create mode 100644 test/unit/non_working_date_import_test.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index e02dace81..65c1c0f1e 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -2249,6 +2249,10 @@ a.icon:hover .icon-svg-filled, a.icon-only:hover .icon-svg-filled { stroke: var(--oc-yellow-8); } +.icon-info svg.icon-svg { + stroke: var(--oc-indigo-9); +} + .icon-only span { display: none; } diff --git a/app/controllers/non_working_dates_controller.rb b/app/controllers/non_working_dates_controller.rb index 2b9e35157..c3a154905 100644 --- a/app/controllers/non_working_dates_controller.rb +++ b/app/controllers/non_working_dates_controller.rb @@ -22,18 +22,27 @@ class NonWorkingDatesController < ApplicationController self.main_menu = false menu_item :settings + include NonWorkingDatesHelper + 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 + respond_to do |format| + format.html do + @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 + format.csv do + send_data(non_working_dates_to_csv(NonWorkingDate.sorted), :type => 'text/csv; header=present', :filename => 'non_working_dates.csv') + end + end end def new diff --git a/app/helpers/non_working_dates_helper.rb b/app/helpers/non_working_dates_helper.rb new file mode 100644 index 000000000..9c7d31bb6 --- /dev/null +++ b/app/helpers/non_working_dates_helper.rb @@ -0,0 +1,29 @@ +# 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 NonWorkingDatesHelper + def non_working_dates_to_csv(non_working_dates) + Redmine::Export::CSV.generate(:encoding => params[:encoding], :field_separator => params[:field_separator]) do |csv| + csv << [l(:field_date), l(:field_name)] + non_working_dates.each do |non_working_date| + csv << [format_date(non_working_date.date), non_working_date.name] + end + end + end +end diff --git a/app/models/non_working_date_import.rb b/app/models/non_working_date_import.rb new file mode 100644 index 000000000..a2543133b --- /dev/null +++ b/app/models/non_working_date_import.rb @@ -0,0 +1,55 @@ +# 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 NonWorkingDateImport < Import + AUTO_MAPPABLE_FIELDS = { + 'date' => 'field_date', + 'name' => 'field_name' + } + + def self.menu_item + :settings + end + + def self.layout + 'admin' + end + + def self.authorized?(user) + user.admin? + end + + # Returns the objects that were imported + def saved_objects + NonWorkingDate.where(:id => saved_items.pluck(:obj_id)).sorted + end + + def mappable_custom_fields + [] + end + + private + + def build_object(row, item) + NonWorkingDate.new( + :date => row_date(row, 'date'), + :name => row_value(row, 'name') + ) + end +end diff --git a/app/views/imports/_non_working_dates_mapping.html.erb b/app/views/imports/_non_working_dates_mapping.html.erb new file mode 100644 index 000000000..c25de99d5 --- /dev/null +++ b/app/views/imports/_non_working_dates_mapping.html.erb @@ -0,0 +1,11 @@ +
+ <%= l(:label_fields_mapping) %> +

+ + <%= mapping_select_tag @import, 'date', :required => true %> +

+

+ + <%= mapping_select_tag @import, 'name' %> +

+
diff --git a/app/views/imports/_non_working_dates_saved_objects.html.erb b/app/views/imports/_non_working_dates_saved_objects.html.erb new file mode 100644 index 000000000..39139fc70 --- /dev/null +++ b/app/views/imports/_non_working_dates_saved_objects.html.erb @@ -0,0 +1,17 @@ + + + + + + + + + <% saved_objects.each do |non_working_date| %> + + + + + <% end %> + +
<%= t(:field_date) %><%= t(:field_name) %>
<%= link_to format_date(non_working_date.date), edit_non_working_date_path(non_working_date) %><%= non_working_date.name %>
+

<%= link_to l(:label_non_working_date_plural), non_working_dates_path(:year => saved_objects.first.date.year) %>

diff --git a/app/views/non_working_dates/index.html.erb b/app/views/non_working_dates/index.html.erb index ee4bd9998..79d7cf778 100644 --- a/app/views/non_working_dates/index.html.erb +++ b/app/views/non_working_dates/index.html.erb @@ -1,5 +1,6 @@
<%= link_to(sprite_icon('add', l(:label_non_working_date_new)), new_non_working_date_path, :class => 'icon icon-add') %> +<%= link_to(sprite_icon('import', l(:button_import)), new_non_working_dates_import_path, :class => 'icon icon-import') %>

<%= l(:label_non_working_date_plural) %>

@@ -51,4 +52,22 @@

<%= l(:label_no_data) %>

<% end %> +<% if @non_working_date_count > 0 %> +<% other_formats_links do |f| %> + <%= f.link_to_with_query_parameters 'CSV', {}, :onclick => "showModal('csv-export-options', '330px'); return false;" %> +<% end %> + +<% end %> + <% html_title(l(:label_non_working_date_plural)) -%> diff --git a/config/locales/en.yml b/config/locales/en.yml index 56db8c32c..276b489f4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1444,12 +1444,14 @@ en: 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' + text_non_working_dates_export_all_years: Non-working dates of all years will be exported. 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. text_project_is_public_anonymous: Public projects and their contents are openly available on the network. label_import_time_entries: Import time entries label_import_users: Import users + label_import_non_working_dates: Import non-working dates sudo_mode_new_info_html: What's happening? You need to reconfirm your password before taking any administrative actions, this ensures your account stays protected. twofa__totp__name: Authenticator app diff --git a/config/routes.rb b/config/routes.rb index f62b94654..4a7d8b678 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -85,6 +85,8 @@ Rails.application.routes.draw do :defaults => {:type => 'TimeEntryImport'}, :as => 'new_time_entries_import' get '/users/imports/new', :to => 'imports#new', :defaults => {:type => 'UserImport'}, :as => 'new_users_import' + get '/non_working_dates/imports/new', :to => 'imports#new', + :defaults => {:type => 'NonWorkingDateImport'}, :as => 'new_non_working_dates_import' post '/imports', :to => 'imports#create', :as => 'imports' get '/imports/:id', :to => 'imports#show', :as => 'import' match '/imports/:id/settings', :to => 'imports#settings', :via => [:get, :post], :as => 'import_settings' diff --git a/test/fixtures/files/import_non_working_dates.csv b/test/fixtures/files/import_non_working_dates.csv new file mode 100644 index 000000000..a33eedb4a --- /dev/null +++ b/test/fixtures/files/import_non_working_dates.csv @@ -0,0 +1,4 @@ +date,name +2026-06-25,Company Foundation Day +2026-08-13,Summer Shutdown +2026-12-30,Year-end Shutdown diff --git a/test/functional/imports_controller_test.rb b/test/functional/imports_controller_test.rb index d9be74b01..b882faa30 100644 --- a/test/functional/imports_controller_test.rb +++ b/test/functional/imports_controller_test.rb @@ -502,4 +502,31 @@ class ImportsControllerTest < Redmine::ControllerTest assert_select 'table#unsaved-items' assert_select 'table#unsaved-items tbody tr', import.unsaved_items.count end + + def test_import_non_working_dates + @request.session[:user_id] = 1 + import = NonWorkingDateImport.new + import.user_id = 1 + import.file = uploaded_test_file('import_non_working_dates.csv', 'text/csv') + import.settings = { + 'separator' => ',', 'wrapper' => '"', 'encoding' => 'UTF-8', 'date_format' => '%Y-%m-%d', + 'mapping' => {'date' => '0', 'name' => '1'} + } + import.save! + + get(:mapping, :params => {:id => import.to_param}) + assert_response :success + assert_select 'select[name=?]', 'import_settings[mapping][date]' + assert_select 'select[name=?]', 'import_settings[mapping][name]' + + assert_difference 'NonWorkingDate.count', 3 do + post(:run, :params => {:id => import.to_param}) + end + assert_redirected_to "/imports/#{import.to_param}" + + get(:show, :params => {:id => import.to_param}) + assert_response :success + assert_select 'table#saved-items tbody tr', 3 + assert_select 'a[href=?]', '/non_working_dates?year=2026', :text => 'Non-working dates' + end end diff --git a/test/functional/non_working_dates_controller_test.rb b/test/functional/non_working_dates_controller_test.rb index 75ec2692b..d8474c687 100644 --- a/test/functional/non_working_dates_controller_test.rb +++ b/test/functional/non_working_dates_controller_test.rb @@ -36,6 +36,7 @@ class NonWorkingDatesControllerTest < Redmine::ControllerTest 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 '#csv-export-options p', :text => 'Non-working dates of all years will be exported.' 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 @@ -55,6 +56,18 @@ class NonWorkingDatesControllerTest < Redmine::ControllerTest assert_select 'table.non_working_dates td.name', :text => 'Year-end Shutdown' end + def test_index_csv_should_export_non_working_dates_of_all_years + NonWorkingDate.create!(:date => '2021-06-25', :name => 'Company Foundation Day') + NonWorkingDate.create!(:date => '2020-12-30') + + with_settings :date_format => '%Y-%m-%d' do + get :index, :params => {:year => '2021', :format => 'csv'} + end + assert_response :success + assert_equal 'text/csv; header=present', @response.media_type + assert_equal ['Date,Name', '2020-12-30,', '2021-06-25,Company Foundation Day'], @response.body.lines.map(&:chomp) + end + def test_index_by_non_admin_should_respond_with_403 @request.session[:user_id] = 2 get :index diff --git a/test/unit/non_working_date_import_test.rb b/test/unit/non_working_date_import_test.rb new file mode 100644 index 000000000..badf455fa --- /dev/null +++ b/test/unit/non_working_date_import_test.rb @@ -0,0 +1,68 @@ +# 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 NonWorkingDateImportTest < ActiveSupport::TestCase + include Redmine::I18n + + def setup + set_language_if_valid 'en' + User.current = nil + end + + def test_run_should_import_dates_and_names + import = generate_import_with_mapping + first, second, third = new_records(NonWorkingDate, 3) {import.run} + + assert_equal Date.new(2026, 6, 25), first.date + assert_equal 'Company Foundation Day', first.name + assert_equal Date.new(2026, 8, 13), second.date + assert_equal 'Summer Shutdown', second.name + assert_equal Date.new(2026, 12, 30), third.date + assert_equal 'Year-end Shutdown', third.name + end + + def test_run_should_not_import_already_registered_dates + NonWorkingDate.create!(:date => '2026-08-13', :name => 'Existing') + import = generate_import_with_mapping + + assert_difference 'NonWorkingDate.count', 2 do + import.run + end + assert_equal 'Existing', NonWorkingDate.find_by(:date => '2026-08-13').name + item = import.unsaved_items.first + assert_equal 2, item.position + assert_include 'Date has already been taken', item.message + end + + protected + + def generate_import_with_mapping + import = NonWorkingDateImport.new + import.user_id = 1 + import.file = uploaded_test_file('import_non_working_dates.csv', 'text/csv') + import.settings = { + 'separator' => ',', 'wrapper' => '"', 'encoding' => 'UTF-8', 'date_format' => '%Y-%m-%d', + 'mapping' => {'date' => '0', 'name' => '1'} + } + import.save! + import + end +end -- 2.55.0