Project

General

Profile

Feature #1459 » 0002-Import-and-export-non-working-dates-as-CSV-files.patch

Go MAEDA, 2026-10-08 10:29

View differences:

app/assets/stylesheets/application.css
2249 2249
  stroke: var(--oc-yellow-8);
2250 2250
}
2251
.icon-info svg.icon-svg {
2252
  stroke: var(--oc-indigo-9);
2253
}
2254

  
2251 2255
.icon-only span {
2252 2256
  display: none;
2253 2257
}
app/controllers/non_working_dates_controller.rb
22 22
  self.main_menu = false
23 23
  menu_item :settings
24
  include NonWorkingDatesHelper
25

  
24 26
  before_action :require_admin
25 27
  before_action :find_non_working_date, :only => [:edit, :update, :destroy]
26 28
  def index
27
    @year = params[:year].to_i if /\A\d{4}\z/.match?(params[:year].to_s)
28
    @year ||= User.current.today.year
29
    years = NonWorkingDate.years
30
    @years = (years | [@year, User.current.today.year]).sort
31
    @previous_year = @year - 1 if years.any? {|year| year < @year}
32
    @next_year = @year + 1 if years.any? {|year| year > @year}
33
    @non_working_dates = NonWorkingDate.in_year(@year).sorted.to_a
34
    @non_working_date_count = NonWorkingDate.count
29
    respond_to do |format|
30
      format.html do
31
        @year = params[:year].to_i if /\A\d{4}\z/.match?(params[:year].to_s)
32
        @year ||= User.current.today.year
33
        years = NonWorkingDate.years
34
        @years = (years | [@year, User.current.today.year]).sort
35
        @previous_year = @year - 1 if years.any? {|year| year < @year}
36
        @next_year = @year + 1 if years.any? {|year| year > @year}
37
        @non_working_dates = NonWorkingDate.in_year(@year).sorted.to_a
38
        @non_working_date_count = NonWorkingDate.count
39
      end
40
      format.csv do
41
        send_data(non_working_dates_to_csv(NonWorkingDate.sorted), :type => 'text/csv; header=present', :filename => 'non_working_dates.csv')
42
      end
43
    end
35 44
  end
36 45
  def new
app/helpers/non_working_dates_helper.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module NonWorkingDatesHelper
21
  def non_working_dates_to_csv(non_working_dates)
22
    Redmine::Export::CSV.generate(:encoding => params[:encoding], :field_separator => params[:field_separator]) do |csv|
23
      csv << [l(:field_date), l(:field_name)]
24
      non_working_dates.each do |non_working_date|
25
        csv << [format_date(non_working_date.date), non_working_date.name]
26
      end
27
    end
28
  end
29
end
app/models/non_working_date_import.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
class NonWorkingDateImport < Import
21
  AUTO_MAPPABLE_FIELDS = {
22
    'date' => 'field_date',
23
    'name' => 'field_name'
24
  }
25

  
26
  def self.menu_item
27
    :settings
28
  end
29

  
30
  def self.layout
31
    'admin'
32
  end
33

  
34
  def self.authorized?(user)
35
    user.admin?
36
  end
37

  
38
  # Returns the objects that were imported
39
  def saved_objects
40
    NonWorkingDate.where(:id => saved_items.pluck(:obj_id)).sorted
41
  end
42

  
43
  def mappable_custom_fields
44
    []
45
  end
46

  
47
  private
48

  
49
  def build_object(row, item)
50
    NonWorkingDate.new(
51
      :date => row_date(row, 'date'),
52
      :name => row_value(row, 'name')
53
    )
54
  end
55
end
app/views/imports/_non_working_dates_mapping.html.erb
1
<fieldset class="box tabular">
2
  <legend><%= l(:label_fields_mapping) %></legend>
3
  <p>
4
    <label for="import_mapping_date"><%= l(:field_date) %></label>
5
    <%= mapping_select_tag @import, 'date', :required => true %>
6
  </p>
7
  <p>
8
    <label for="import_mapping_name"><%= l(:field_name) %></label>
9
    <%= mapping_select_tag @import, 'name' %>
10
  </p>
11
</fieldset>
app/views/imports/_non_working_dates_saved_objects.html.erb
1
<table id="saved-items" class="list">
2
  <thead>
3
  <tr>
4
    <th><%= t(:field_date) %></th>
5
    <th><%= t(:field_name) %></th>
6
  </tr>
7
  </thead>
8
  <tbody>
9
  <% saved_objects.each do |non_working_date| %>
10
  <tr>
11
    <td><%= link_to format_date(non_working_date.date), edit_non_working_date_path(non_working_date) %></td>
12
    <td><%= non_working_date.name %></td>
13
  </tr>
14
  <% end %>
15
  </tbody>
16
</table>
17
<p><%= link_to l(:label_non_working_date_plural), non_working_dates_path(:year => saved_objects.first.date.year) %></p>
app/views/non_working_dates/index.html.erb
1 1
<div class="contextual">
2 2
<%= link_to(sprite_icon('add', l(:label_non_working_date_new)), new_non_working_date_path, :class => 'icon icon-add') %>
3
<%= link_to(sprite_icon('import', l(:button_import)), new_non_working_dates_import_path, :class => 'icon icon-import') %>
3 4
</div>
4 5
<h2><%= l(:label_non_working_date_plural) %></h2>
......
51 52
<p class="nodata"><%= l(:label_no_data) %></p>
52 53
<% end %>
54
<% if @non_working_date_count > 0 %>
55
<% other_formats_links do |f| %>
56
  <%= f.link_to_with_query_parameters 'CSV', {}, :onclick => "showModal('csv-export-options', '330px'); return false;" %>
57
<% end %>
58
<div id="csv-export-options" style="display: none;">
59
  <h3 class="title"><%= l(:label_export_options, :export_format => 'CSV') %></h3>
60
  <%= form_tag(non_working_dates_path(:format => 'csv'), :method => :get, :id => 'csv-export-form') do %>
61
  <%= export_csv_encoding_select_tag %>
62
  <%= export_csv_separator_select_tag %>
63
  <p class="icon icon-info"><%= sprite_icon('help', l(:text_non_working_dates_export_all_years)) %></p>
64
  <p class="buttons">
65
    <%= submit_tag l(:button_export), :name => nil, :onclick => 'hideModal(this);', :data => {:disable_with => false} %>
66
    <%= link_to_function l(:button_cancel), 'hideModal(this);' %>
67
  </p>
68
  <% end %>
69
</div>
70
<% end %>
71

  
53 72
<% html_title(l(:label_non_working_date_plural)) -%>
config/locales/en.yml
1444 1444
  text_repository_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.<br />Once saved, the identifier cannot be changed.'
1445 1445
  text_login_required_html: When not requiring authentication, public projects and their contents are openly available on the network. You can <a href="%{anonymous_role_path}">edit the applicable permissions</a>.
1446 1446
  text_non_working_dates_html: 'You can add non-working dates such as holidays: <a href="%{non_working_dates_path}">Manage non-working dates</a>'
1447
  text_non_working_dates_export_all_years: Non-working dates of all years will be exported.
1447 1448
  label_login_required_yes: "Yes"
1448 1449
  label_login_required_no: "No, allow anonymous access to public projects"
1449 1450
  text_project_is_public_non_member: Public projects and their contents are available to all logged-in users.
1450 1451
  text_project_is_public_anonymous: Public projects and their contents are openly available on the network.
1451 1452
  label_import_time_entries: Import time entries
1452 1453
  label_import_users: Import users
1454
  label_import_non_working_dates: Import non-working dates
1453 1455
  sudo_mode_new_info_html: <strong>What's happening?</strong> You need to reconfirm your password before taking any administrative actions, this ensures your account stays protected.
1454 1456
  twofa__totp__name: Authenticator app
config/routes.rb
85 85
        :defaults => {:type => 'TimeEntryImport'}, :as => 'new_time_entries_import'
86 86
  get   '/users/imports/new', :to => 'imports#new',
87 87
        :defaults => {:type => 'UserImport'}, :as => 'new_users_import'
88
  get   '/non_working_dates/imports/new', :to => 'imports#new',
89
        :defaults => {:type => 'NonWorkingDateImport'}, :as => 'new_non_working_dates_import'
88 90
  post  '/imports', :to => 'imports#create', :as => 'imports'
89 91
  get   '/imports/:id', :to => 'imports#show', :as => 'import'
90 92
  match '/imports/:id/settings', :to => 'imports#settings', :via => [:get, :post], :as => 'import_settings'
test/fixtures/files/import_non_working_dates.csv
1
date,name
2
2026-06-25,Company Foundation Day
3
2026-08-13,Summer Shutdown
4
2026-12-30,Year-end Shutdown
test/functional/imports_controller_test.rb
502 502
    assert_select 'table#unsaved-items'
503 503
    assert_select 'table#unsaved-items tbody tr', import.unsaved_items.count
504 504
  end
505

  
506
  def test_import_non_working_dates
507
    @request.session[:user_id] = 1
508
    import = NonWorkingDateImport.new
509
    import.user_id = 1
510
    import.file = uploaded_test_file('import_non_working_dates.csv', 'text/csv')
511
    import.settings = {
512
      'separator' => ',', 'wrapper' => '"', 'encoding' => 'UTF-8', 'date_format' => '%Y-%m-%d',
513
      'mapping' => {'date' => '0', 'name' => '1'}
514
    }
515
    import.save!
516

  
517
    get(:mapping, :params => {:id => import.to_param})
518
    assert_response :success
519
    assert_select 'select[name=?]', 'import_settings[mapping][date]'
520
    assert_select 'select[name=?]', 'import_settings[mapping][name]'
521

  
522
    assert_difference 'NonWorkingDate.count', 3 do
523
      post(:run, :params => {:id => import.to_param})
524
    end
525
    assert_redirected_to "/imports/#{import.to_param}"
526

  
527
    get(:show, :params => {:id => import.to_param})
528
    assert_response :success
529
    assert_select 'table#saved-items tbody tr', 3
530
    assert_select 'a[href=?]', '/non_working_dates?year=2026', :text => 'Non-working dates'
531
  end
505 532
end
test/functional/non_working_dates_controller_test.rb
36 36
    assert_select 'table.non_working_dates td.name', :text => 'Company Foundation Day'
37 37
    assert_select 'form[data-cm-url=?] input[name=?]', '/non_working_dates/context_menu', 'ids[]', 1
38 38
    assert_select 'span.pagination span.items', :text => '(1/2)'
39
    assert_select '#csv-export-options p', :text => 'Non-working dates of all years will be exported.'
39 40
    assert_select 'form.non-working-dates-year' do
40 41
      assert_select 'select[name=year] option', 2
41 42
      assert_select 'select[name=year] option[selected=selected]', :text => year.to_s
......
55 56
    assert_select 'table.non_working_dates td.name', :text => 'Year-end Shutdown'
56 57
  end
58
  def test_index_csv_should_export_non_working_dates_of_all_years
59
    NonWorkingDate.create!(:date => '2021-06-25', :name => 'Company Foundation Day')
60
    NonWorkingDate.create!(:date => '2020-12-30')
61

  
62
    with_settings :date_format => '%Y-%m-%d' do
63
      get :index, :params => {:year => '2021', :format => 'csv'}
64
    end
65
    assert_response :success
66
    assert_equal 'text/csv; header=present', @response.media_type
67
    assert_equal ['Date,Name', '2020-12-30,', '2021-06-25,Company Foundation Day'], @response.body.lines.map(&:chomp)
68
  end
69

  
57 70
  def test_index_by_non_admin_should_respond_with_403
58 71
    @request.session[:user_id] = 2
59 72
    get :index
test/unit/non_working_date_import_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../test_helper'
21

  
22
class NonWorkingDateImportTest < ActiveSupport::TestCase
23
  include Redmine::I18n
24

  
25
  def setup
26
    set_language_if_valid 'en'
27
    User.current = nil
28
  end
29

  
30
  def test_run_should_import_dates_and_names
31
    import = generate_import_with_mapping
32
    first, second, third = new_records(NonWorkingDate, 3) {import.run}
33

  
34
    assert_equal Date.new(2026, 6, 25), first.date
35
    assert_equal 'Company Foundation Day', first.name
36
    assert_equal Date.new(2026, 8, 13), second.date
37
    assert_equal 'Summer Shutdown', second.name
38
    assert_equal Date.new(2026, 12, 30), third.date
39
    assert_equal 'Year-end Shutdown', third.name
40
  end
41

  
42
  def test_run_should_not_import_already_registered_dates
43
    NonWorkingDate.create!(:date => '2026-08-13', :name => 'Existing')
44
    import = generate_import_with_mapping
45

  
46
    assert_difference 'NonWorkingDate.count', 2 do
47
      import.run
48
    end
49
    assert_equal 'Existing', NonWorkingDate.find_by(:date => '2026-08-13').name
50
    item = import.unsaved_items.first
51
    assert_equal 2, item.position
52
    assert_include 'Date has already been taken', item.message
53
  end
54

  
55
  protected
56

  
57
  def generate_import_with_mapping
58
    import = NonWorkingDateImport.new
59
    import.user_id = 1
60
    import.file = uploaded_test_file('import_non_working_dates.csv', 'text/csv')
61
    import.settings = {
62
      'separator' => ',', 'wrapper' => '"', 'encoding' => 'UTF-8', 'date_format' => '%Y-%m-%d',
63
      'mapping' => {'date' => '0', 'name' => '1'}
64
    }
65
    import.save!
66
    import
67
  end
68
end
(8-8/8)