Feature #28234 » 0002-Import-time-entries.patch
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 |