Project

General

Profile

Feature #28234 » 0002-Import-time-entries.patch

updated version (v2) - Gregor Schmidt, 2018-03-05 13:09

View differences:

app/models/time_entry_import.rb
1
class TimeEntryImport < Import
2
  def self.menu_item
3
    :time_entries
4
  end
5

  
6
  def self.authorized?(user)
7
    user.allowed_to?(:log_time, nil, :global => true)
8
  end
9

  
10
  # Returns the objects that were imported
11
  def saved_objects
12
    TimeEntry.where(:id => saved_items.pluck(:obj_id)).order(:id).preload(:activity, :project, :issue => [:tracker, :priority, :status])
13
  end
14

  
15
  def mappable_custom_fields
16
    TimeEntryCustomField.all
17
  end
18

  
19
  def allowed_target_projects
20
    Project.allowed_to(user, :log_time).order(:lft)
21
  end
22

  
23
  def allowed_target_activities
24
    project.activities
25
  end
26

  
27
  def project
28
    project_id = mapping['project_id'].to_i
29
    allowed_target_projects.find_by_id(project_id) || allowed_target_projects.first
30
  end
31

  
32
  def activity
33
    if mapping['activity'].to_s =~ /\Avalue:(\d+)\z/
34
      activity_id = $1.to_i
35
      allowed_target_activities.find_by_id(activity_id)
36
    end
37
  end
38

  
39
  private
40

  
41

  
42
  def build_object(row, item)
43
    object = TimeEntry.new
44
    object.user = user
45

  
46
    activity_id = nil
47
    if activity
48
      activity_id = activity.id
49
    elsif activity_name = row_value(row, 'activity')
50
      activity_id = allowed_target_activities.named(activity_name).first.try(:id)
51
    end
52

  
53
    attributes = {
54
      :project_id  => project.id,
55
      :activity_id => activity_id,
56

  
57
      :issue_id    => row_value(row, 'issue_id'),
58
      :spent_on    => row_date(row, 'spent_on'),
59
      :hours       => row_value(row, 'hours'),
60
      :comments    => row_value(row, 'comments')
61
    }
62

  
63
    attributes['custom_field_values'] = object.custom_field_values.inject({}) do |h, v|
64
      value =
65
        case v.custom_field.field_format
66
        when 'date'
67
          row_date(row, "cf_#{v.custom_field.id}")
68
        else
69
          row_value(row, "cf_#{v.custom_field.id}")
70
        end
71
      if value
72
        h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(value, object)
73
      end
74
      h
75
    end
76

  
77
    object.send(:safe_attributes=, attributes, user)
78
    object
79
  end
80
end
app/views/imports/_time_entries_fields_mapping.html.erb
1
<p>
2
  <label for="import_mapping_project_id"><%= l(:label_project) %></label>
3
  <%= select_tag 'import_settings[mapping][project_id]',
4
        options_for_select(project_tree_options_for_select(@import.allowed_target_projects, :selected => @import.project)),
5
        :id => 'import_mapping_project_id' %>
6
</p>
7
<p>
8
  <label for="import_mapping_activity"><%= l(:field_activity) %></label>
9
  <%= mapping_select_tag @import, 'activity', :required => true,
10
        :values => @import.allowed_target_activities.sorted.map {|t| [t.name, t.id]} %>
11
</p>
12

  
13

  
14
<div class="splitcontent">
15
<div class="splitcontentleft">
16
<p>
17
  <label for="import_mapping_issue_id"><%= l(:field_issue) %></label>
18
  <%= mapping_select_tag @import, 'issue_id' %>
19
</p>
20
<p>
21
  <label for="import_mapping_spent_on"><%= l(:field_spent_on) %></label>
22
  <%= mapping_select_tag @import, 'spent_on', :required => true %>
23
</p>
24
<p>
25
  <label for="import_mapping_hours"><%= l(:field_hours) %></label>
26
  <%= mapping_select_tag @import, 'hours', :required => true %>
27
</p>
28
<p>
29
  <label for="import_mapping_comments"><%= l(:field_comments) %></label>
30
  <%= mapping_select_tag @import, 'comments' %>
31
</p>
32
</div>
33

  
34
<div class="splitcontentright">
35
<% @custom_fields.each do |field| %>
36
  <p>
37
    <label for="import_mapping_cf_<%= field.id %>"><%= field.name %></label>
38
    <%= mapping_select_tag @import, "cf_#{field.id}", :required => field.is_required? %>
39
  </p>
40
<% end %>
41
</div>
app/views/imports/_time_entries_mapping.html.erb
1
<fieldset class="box tabular">
2
  <legend><%= l(:label_fields_mapping) %></legend>
3
  <div id="fields-mapping">
4
    <%= render :partial => 'time_entries_fields_mapping' %>
5
  </div>
6
</fieldset>
7

  
8
<%= javascript_tag do %>
9
$(document).ready(function() {
10
  $('#fields-mapping').on('change', '#import_mapping_project_id', function(){
11
    $.ajax({
12
      url: '<%= import_mapping_path(@import, :format => 'js') %>',
13
      type: 'post',
14
      data: $('#import-form').serialize()
15
    });
16
  });
17
});
18
<% end %>
app/views/imports/_time_entries_mapping.js.erb
1
$('#fields-mapping').html('<%= escape_javascript(render :partial => 'time_entries_fields_mapping') %>');
app/views/imports/_time_entries_saved_objects.html.erb
1
<table id="saved-items" class="list">
2
  <thead>
3
  <tr>
4
    <th><%= t(:field_project) %></th>
5
    <th><%= t(:field_activity) %></th>
6
    <th><%= t(:field_issue) %></th>
7
    <th><%= t(:field_spent_on) %></th>
8
    <th><%= t(:field_hours) %></th>
9
    <th><%= t(:field_comments) %></th>
10
  </tr>
11
  </thead>
12
  <tbody>
13
  <% saved_objects.each do |time_entry| %>
14
  <tr>
15
    <td><%= link_to_project(time_entry.project, :jump => 'time_entries') if time_entry.project %></td>
16
    <td><%= time_entry.activity.name if time_entry.activity %></td>
17
    <td><%= link_to_issue time_entry.issue if time_entry.issue %></td>
18
    <td><%= format_date(time_entry.spent_on) %></td>
19
    <td><%= l_hours_short(time_entry.hours) %></td>
20
    <td><%= time_entry.comments %></td>
21
  </tr>
22
  <% end %>
23
  </tbody>
24
</table>
app/views/imports/_time_entries_sidebar.html.erb
1
<% content_for :sidebar do %>
2
  <%= render :partial => 'timelog/sidebar' %>
3
<% end %>
app/views/timelog/_sidebar.html.erb
1
<h3><%= l(:label_spent_time) %></h3>
2

  
3
<ul>
4
  <li><%= link_to l(:label_time_entries_visibility_all), _time_entries_path(@project, nil, :set_filter => 1) %></li>
5
  <% if User.current.allowed_to?(:log_time, @project, :global => true) %>
6
    <li><%= link_to l(:button_import), new_time_entries_import_path %></li>
7
  <% end %>
8
</ul>
9

  
10
<%= render_sidebar_queries(TimeEntryQuery, @project) %>
app/views/timelog/index.html.erb
1 1
<div class="contextual">
2
<%= link_to l(:button_log_time), 
2
<%= link_to l(:button_log_time),
3 3
            _new_time_entry_path(@project, @query.filtered_issue_id),
4 4
            :class => 'icon icon-time-add' if User.current.allowed_to?(:log_time, @project, :global => true) %>
5 5
</div>
......
41 41
<% end %>
42 42

  
43 43
<% content_for :sidebar do %>
44
  <%= render_sidebar_queries(TimeEntryQuery, @project) %>
44
  <%= render :partial => 'timelog/sidebar' %>
45 45
<% end %>
46 46

  
47 47
<% html_title(@query.new_record? ? l(:label_spent_time) : @query.name, l(:label_details)) %>
app/views/timelog/report.html.erb
1 1
<div class="contextual">
2
<%= link_to l(:button_log_time), 
2
<%= link_to l(:button_log_time),
3 3
            _new_time_entry_path(@project, @issue),
4 4
            :class => 'icon icon-time-add' if User.current.allowed_to?(:log_time, @project, :global => true) %>
5 5
</div>
......
69 69
<% end %>
70 70

  
71 71
<% content_for :sidebar do %>
72
  <%= render_sidebar_queries(TimeEntryQuery, @project) %>
72
  <%= render :partial => 'sidebar' %>
73 73
<% end %>
74 74

  
75 75
<% html_title(@query.new_record? ? l(:label_spent_time) : @query.name, l(:label_report)) %>
config/locales/en.yml
1002 1002
  label_member_management_all_roles: All roles
1003 1003
  label_member_management_selected_roles_only: Only these roles
1004 1004
  label_import_issues: Import issues
1005
  label_import_time_entries: Import time entries
1005 1006
  label_select_file_to_import: Select the file to import
1006 1007
  label_fields_separator: Field separator
1007 1008
  label_fields_wrapper: Field wrapper
config/routes.rb
64 64
  get 'projects/:id/issues/report/:detail', :to => 'reports#issue_report_details', :as => 'project_issues_report_details'
65 65

  
66 66
  get   '/issues/imports/new', :to => 'imports#new', :defaults => { :type => 'IssueImport' }, :as => 'new_issues_import'
67
  get   '/time_entries/imports/new', :to => 'imports#new', :defaults => { :type => 'TimeEntryImport' }, :as => 'new_time_entries_import'
67 68
  post  '/imports', :to => 'imports#create', :as => 'imports'
68 69
  get   '/imports/:id', :to => 'imports#show', :as => 'import'
69 70
  match '/imports/:id/settings', :to => 'imports#settings', :via => [:get, :post], :as => 'import_settings'
......
156 157
        end
157 158
      end
158 159
    end
159
  
160

  
160 161
    match 'wiki/index', :controller => 'wiki', :action => 'index', :via => :get
161 162
    resources :wiki, :except => [:index, :create], :as => 'wiki_page' do
162 163
      member do
test/fixtures/files/import_time_entries.csv
1
row;issue_id;date;hours;comment;activity;overtime
2
1;;2020-01-01;1;Some Design;Design;yes
3
2;;2020-01-02;2;Some Development;Development;yes
4
3;1;2020-01-03;3;Some QA;QA;no
5
4;2;2020-01-04;4;Some Inactivity;Inactive Activity;no
test/unit/time_entry_import_test.rb
1
require File.expand_path('../../test_helper', __FILE__)
2

  
3
class TimeEntryImportTest < ActiveSupport::TestCase
4
  fixtures :projects, :enabled_modules,
5
           :users, :email_addresses,
6
           :roles, :members, :member_roles,
7
           :issues, :issue_statuses,
8
           :trackers, :projects_trackers,
9
           :versions,
10
           :issue_categories,
11
           :enumerations,
12
           :workflows,
13
           :custom_fields,
14
           :custom_values
15

  
16
  include Redmine::I18n
17

  
18
  def setup
19
    set_language_if_valid 'en'
20
  end
21

  
22
  def test_authorized
23
    assert  TimeEntryImport.authorized?(User.find(1)) # admins
24
    assert  TimeEntryImport.authorized?(User.find(2)) # has log_time permission
25
    assert !TimeEntryImport.authorized?(User.find(6)) # anonymous does not have log_time permission
26
  end
27

  
28
  def test_maps_issue_id
29
    import = generate_import_with_mapping
30
    first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
31

  
32
    assert_nil first.issue_id
33
    assert_nil second.issue_id
34
    assert_equal 1, third.issue_id
35
    assert_equal 2, fourth.issue_id
36
  end
37

  
38
  def test_maps_date
39
    import = generate_import_with_mapping
40
    first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
41

  
42
    assert_equal Date.new(2020, 1, 1), first.spent_on
43
    assert_equal Date.new(2020, 1, 2), second.spent_on
44
    assert_equal Date.new(2020, 1, 3), third.spent_on
45
    assert_equal Date.new(2020, 1, 4), fourth.spent_on
46
  end
47

  
48
  def test_maps_hours
49
    import = generate_import_with_mapping
50
    first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
51

  
52
    assert_equal 1, first.hours
53
    assert_equal 2, second.hours
54
    assert_equal 3, third.hours
55
    assert_equal 4, fourth.hours
56
  end
57

  
58
  def test_maps_comments
59
    import = generate_import_with_mapping
60
    first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
61

  
62
    assert_equal 'Some Design',      first.comments
63
    assert_equal 'Some Development', second.comments
64
    assert_equal 'Some QA',          third.comments
65
    assert_equal 'Some Inactivity',  fourth.comments
66
  end
67

  
68
  def test_maps_activity_to_column_value
69
    import = generate_import_with_mapping
70
    import.mapping.merge!('activity' => '5')
71
    import.save!
72

  
73
    # N.B. last row is not imported due to the usage of a disabled activity
74
    first, second, third = new_records(TimeEntry, 3) { import.run }
75

  
76
    assert_equal 9,  first.activity_id
77
    assert_equal 10, second.activity_id
78
    assert_equal 11, third.activity_id
79

  
80
    last = import.items.last
81
    assert_equal 'Activity cannot be blank', last.message
82
    assert_nil last.obj_id
83
  end
84

  
85
  def test_maps_activity_to_fixed_value
86
    import = generate_import_with_mapping
87
    first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
88

  
89
    assert_equal 10, first.activity_id
90
    assert_equal 10, second.activity_id
91
    assert_equal 10, third.activity_id
92
    assert_equal 10, fourth.activity_id
93
  end
94

  
95
  def test_maps_custom_fields
96
    overtime_cf = CustomField.find(10)
97

  
98
    import = generate_import_with_mapping
99
    import.mapping.merge!('cf_10' => '6')
100
    import.save!
101
    first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
102

  
103
    assert_equal '1', first.custom_field_value(overtime_cf)
104
    assert_equal '1', second.custom_field_value(overtime_cf)
105
    assert_equal '0', third.custom_field_value(overtime_cf)
106
    assert_equal '0', fourth.custom_field_value(overtime_cf)
107
  end
108

  
109
  protected
110

  
111
  def generate_import(fixture_name='import_time_entries.csv')
112
    import = TimeEntryImport.new
113
    import.user_id = 2
114
    import.file = uploaded_test_file(fixture_name, 'text/csv')
115
    import.save!
116
    import
117
  end
118

  
119
  def generate_import_with_mapping(fixture_name='import_time_entries.csv')
120
    import = generate_import(fixture_name)
121

  
122
    import.settings = {
123
      'separator' => ';', 'wrapper' => '"', 'encoding' => 'UTF-8',
124
      'mapping' => {
125
        'project_id' => '1',
126
        'activity'   => 'value:10',
127
        'issue_id'   => '1',
128
        'spent_on'   => '2',
129
        'hours'      => '3',
130
        'comments'   => '4'
131
      }
132
    }
133
    import.save!
134
    import
135
  end
136
end
(1-1/4)