mirror of
https://github.com/meineerde/redmine.git
synced 2025-12-19 15:01:14 +00:00
Import time entries (#28234).
Patch by Gregor Schmidt. git-svn-id: http://svn.redmine.org/redmine/trunk@18146 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
parent
b540046ed7
commit
6fd9d9ed73
80
app/models/time_entry_import.rb
Normal file
80
app/models/time_entry_import.rb
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
class TimeEntryImport < Import
|
||||||
|
def self.menu_item
|
||||||
|
:time_entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.authorized?(user)
|
||||||
|
user.allowed_to?(:log_time, nil, :global => true)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the objects that were imported
|
||||||
|
def saved_objects
|
||||||
|
TimeEntry.where(:id => saved_items.pluck(:obj_id)).order(:id).preload(:activity, :project, :issue => [:tracker, :priority, :status])
|
||||||
|
end
|
||||||
|
|
||||||
|
def mappable_custom_fields
|
||||||
|
TimeEntryCustomField.all
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_target_projects
|
||||||
|
Project.allowed_to(user, :log_time).order(:lft)
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_target_activities
|
||||||
|
project.activities
|
||||||
|
end
|
||||||
|
|
||||||
|
def project
|
||||||
|
project_id = mapping['project_id'].to_i
|
||||||
|
allowed_target_projects.find_by_id(project_id) || allowed_target_projects.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def activity
|
||||||
|
if mapping['activity'].to_s =~ /\Avalue:(\d+)\z/
|
||||||
|
activity_id = $1.to_i
|
||||||
|
allowed_target_activities.find_by_id(activity_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
|
||||||
|
def build_object(row, item)
|
||||||
|
object = TimeEntry.new
|
||||||
|
object.user = user
|
||||||
|
|
||||||
|
activity_id = nil
|
||||||
|
if activity
|
||||||
|
activity_id = activity.id
|
||||||
|
elsif activity_name = row_value(row, 'activity')
|
||||||
|
activity_id = allowed_target_activities.named(activity_name).first.try(:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes = {
|
||||||
|
:project_id => project.id,
|
||||||
|
:activity_id => activity_id,
|
||||||
|
|
||||||
|
:issue_id => row_value(row, 'issue_id'),
|
||||||
|
:spent_on => row_date(row, 'spent_on'),
|
||||||
|
:hours => row_value(row, 'hours'),
|
||||||
|
:comments => row_value(row, 'comments')
|
||||||
|
}
|
||||||
|
|
||||||
|
attributes['custom_field_values'] = object.custom_field_values.inject({}) do |h, v|
|
||||||
|
value =
|
||||||
|
case v.custom_field.field_format
|
||||||
|
when 'date'
|
||||||
|
row_date(row, "cf_#{v.custom_field.id}")
|
||||||
|
else
|
||||||
|
row_value(row, "cf_#{v.custom_field.id}")
|
||||||
|
end
|
||||||
|
if value
|
||||||
|
h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(value, object)
|
||||||
|
end
|
||||||
|
h
|
||||||
|
end
|
||||||
|
|
||||||
|
object.send(:safe_attributes=, attributes, user)
|
||||||
|
object
|
||||||
|
end
|
||||||
|
end
|
||||||
41
app/views/imports/_time_entries_fields_mapping.html.erb
Normal file
41
app/views/imports/_time_entries_fields_mapping.html.erb
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<p>
|
||||||
|
<label for="import_mapping_project_id"><%= l(:label_project) %></label>
|
||||||
|
<%= select_tag 'import_settings[mapping][project_id]',
|
||||||
|
options_for_select(project_tree_options_for_select(@import.allowed_target_projects, :selected => @import.project)),
|
||||||
|
:id => 'import_mapping_project_id' %>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="import_mapping_activity"><%= l(:field_activity) %></label>
|
||||||
|
<%= mapping_select_tag @import, 'activity', :required => true,
|
||||||
|
:values => @import.allowed_target_activities.sorted.map {|t| [t.name, t.id]} %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="splitcontent">
|
||||||
|
<div class="splitcontentleft">
|
||||||
|
<p>
|
||||||
|
<label for="import_mapping_issue_id"><%= l(:field_issue) %></label>
|
||||||
|
<%= mapping_select_tag @import, 'issue_id' %>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="import_mapping_spent_on"><%= l(:field_spent_on) %></label>
|
||||||
|
<%= mapping_select_tag @import, 'spent_on', :required => true %>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="import_mapping_hours"><%= l(:field_hours) %></label>
|
||||||
|
<%= mapping_select_tag @import, 'hours', :required => true %>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="import_mapping_comments"><%= l(:field_comments) %></label>
|
||||||
|
<%= mapping_select_tag @import, 'comments' %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="splitcontentright">
|
||||||
|
<% @custom_fields.each do |field| %>
|
||||||
|
<p>
|
||||||
|
<label for="import_mapping_cf_<%= field.id %>"><%= field.name %></label>
|
||||||
|
<%= mapping_select_tag @import, "cf_#{field.id}", :required => field.is_required? %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
18
app/views/imports/_time_entries_mapping.html.erb
Normal file
18
app/views/imports/_time_entries_mapping.html.erb
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<fieldset class="box tabular">
|
||||||
|
<legend><%= l(:label_fields_mapping) %></legend>
|
||||||
|
<div id="fields-mapping">
|
||||||
|
<%= render :partial => 'time_entries_fields_mapping' %>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<%= javascript_tag do %>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#fields-mapping').on('change', '#import_mapping_project_id', function(){
|
||||||
|
$.ajax({
|
||||||
|
url: '<%= import_mapping_path(@import, :format => 'js') %>',
|
||||||
|
type: 'post',
|
||||||
|
data: $('#import-form').serialize()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
<% end %>
|
||||||
1
app/views/imports/_time_entries_mapping.js.erb
Normal file
1
app/views/imports/_time_entries_mapping.js.erb
Normal file
@ -0,0 +1 @@
|
|||||||
|
$('#fields-mapping').html('<%= escape_javascript(render :partial => 'time_entries_fields_mapping') %>');
|
||||||
24
app/views/imports/_time_entries_saved_objects.html.erb
Normal file
24
app/views/imports/_time_entries_saved_objects.html.erb
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<table id="saved-items" class="list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%= t(:field_project) %></th>
|
||||||
|
<th><%= t(:field_activity) %></th>
|
||||||
|
<th><%= t(:field_issue) %></th>
|
||||||
|
<th><%= t(:field_spent_on) %></th>
|
||||||
|
<th><%= t(:field_hours) %></th>
|
||||||
|
<th><%= t(:field_comments) %></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% saved_objects.each do |time_entry| %>
|
||||||
|
<tr>
|
||||||
|
<td><%= link_to_project(time_entry.project, :jump => 'time_entries') if time_entry.project %></td>
|
||||||
|
<td><%= time_entry.activity.name if time_entry.activity %></td>
|
||||||
|
<td><%= link_to_issue time_entry.issue if time_entry.issue %></td>
|
||||||
|
<td><%= format_date(time_entry.spent_on) %></td>
|
||||||
|
<td><%= l_hours_short(time_entry.hours) %></td>
|
||||||
|
<td><%= time_entry.comments %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
3
app/views/imports/_time_entries_sidebar.html.erb
Normal file
3
app/views/imports/_time_entries_sidebar.html.erb
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<% content_for :sidebar do %>
|
||||||
|
<%= render :partial => 'timelog/sidebar' %>
|
||||||
|
<% end %>
|
||||||
10
app/views/timelog/_sidebar.html.erb
Normal file
10
app/views/timelog/_sidebar.html.erb
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<h3><%= l(:label_spent_time) %></h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><%= link_to l(:label_time_entries_visibility_all), _time_entries_path(@project, nil, :set_filter => 1) %></li>
|
||||||
|
<% if User.current.allowed_to?(:log_time, @project, :global => true) %>
|
||||||
|
<li><%= link_to l(:button_import), new_time_entries_import_path %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<%= render_sidebar_queries(TimeEntryQuery, @project) %>
|
||||||
@ -42,7 +42,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% content_for :sidebar do %>
|
<% content_for :sidebar do %>
|
||||||
<%= render_sidebar_queries(TimeEntryQuery, @project) %>
|
<%= render :partial => 'timelog/sidebar' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% html_title(@query.new_record? ? l(:label_spent_time) : @query.name, l(:label_details)) %>
|
<% html_title(@query.new_record? ? l(:label_spent_time) : @query.name, l(:label_details)) %>
|
||||||
|
|||||||
@ -78,7 +78,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% content_for :sidebar do %>
|
<% content_for :sidebar do %>
|
||||||
<%= render_sidebar_queries(TimeEntryQuery, @project) %>
|
<%= render :partial => 'sidebar' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% html_title(@query.new_record? ? l(:label_spent_time) : @query.name, l(:label_report)) %>
|
<% html_title(@query.new_record? ? l(:label_spent_time) : @query.name, l(:label_report)) %>
|
||||||
|
|||||||
@ -1022,6 +1022,7 @@ en:
|
|||||||
label_member_management_all_roles: All roles
|
label_member_management_all_roles: All roles
|
||||||
label_member_management_selected_roles_only: Only these roles
|
label_member_management_selected_roles_only: Only these roles
|
||||||
label_import_issues: Import issues
|
label_import_issues: Import issues
|
||||||
|
label_import_time_entries: Import time entries
|
||||||
label_select_file_to_import: Select the file to import
|
label_select_file_to_import: Select the file to import
|
||||||
label_fields_separator: Field separator
|
label_fields_separator: Field separator
|
||||||
label_fields_wrapper: Field wrapper
|
label_fields_wrapper: Field wrapper
|
||||||
|
|||||||
@ -65,6 +65,7 @@ Rails.application.routes.draw do
|
|||||||
get 'projects/:id/issues/report/:detail', :to => 'reports#issue_report_details', :as => 'project_issues_report_details'
|
get 'projects/:id/issues/report/:detail', :to => 'reports#issue_report_details', :as => 'project_issues_report_details'
|
||||||
|
|
||||||
get '/issues/imports/new', :to => 'imports#new', :defaults => { :type => 'IssueImport' }, :as => 'new_issues_import'
|
get '/issues/imports/new', :to => 'imports#new', :defaults => { :type => 'IssueImport' }, :as => 'new_issues_import'
|
||||||
|
get '/time_entries/imports/new', :to => 'imports#new', :defaults => { :type => 'TimeEntryImport' }, :as => 'new_time_entries_import'
|
||||||
post '/imports', :to => 'imports#create', :as => 'imports'
|
post '/imports', :to => 'imports#create', :as => 'imports'
|
||||||
get '/imports/:id', :to => 'imports#show', :as => 'import'
|
get '/imports/:id', :to => 'imports#show', :as => 'import'
|
||||||
match '/imports/:id/settings', :to => 'imports#settings', :via => [:get, :post], :as => 'import_settings'
|
match '/imports/:id/settings', :to => 'imports#settings', :via => [:get, :post], :as => 'import_settings'
|
||||||
|
|||||||
5
test/fixtures/files/import_time_entries.csv
vendored
Normal file
5
test/fixtures/files/import_time_entries.csv
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
row;issue_id;date;hours;comment;activity;overtime
|
||||||
|
1;;2020-01-01;1;Some Design;Design;yes
|
||||||
|
2;;2020-01-02;2;Some Development;Development;yes
|
||||||
|
3;1;2020-01-03;3;Some QA;QA;no
|
||||||
|
4;2;2020-01-04;4;Some Inactivity;Inactive Activity;no
|
||||||
|
136
test/unit/time_entry_import_test.rb
Normal file
136
test/unit/time_entry_import_test.rb
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
require File.expand_path('../../test_helper', __FILE__)
|
||||||
|
|
||||||
|
class TimeEntryImportTest < ActiveSupport::TestCase
|
||||||
|
fixtures :projects, :enabled_modules,
|
||||||
|
:users, :email_addresses,
|
||||||
|
:roles, :members, :member_roles,
|
||||||
|
:issues, :issue_statuses,
|
||||||
|
:trackers, :projects_trackers,
|
||||||
|
:versions,
|
||||||
|
:issue_categories,
|
||||||
|
:enumerations,
|
||||||
|
:workflows,
|
||||||
|
:custom_fields,
|
||||||
|
:custom_values
|
||||||
|
|
||||||
|
include Redmine::I18n
|
||||||
|
|
||||||
|
def setup
|
||||||
|
set_language_if_valid 'en'
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_authorized
|
||||||
|
assert TimeEntryImport.authorized?(User.find(1)) # admins
|
||||||
|
assert TimeEntryImport.authorized?(User.find(2)) # has log_time permission
|
||||||
|
assert !TimeEntryImport.authorized?(User.find(6)) # anonymous does not have log_time permission
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_maps_issue_id
|
||||||
|
import = generate_import_with_mapping
|
||||||
|
first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
|
||||||
|
|
||||||
|
assert_nil first.issue_id
|
||||||
|
assert_nil second.issue_id
|
||||||
|
assert_equal 1, third.issue_id
|
||||||
|
assert_equal 2, fourth.issue_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_maps_date
|
||||||
|
import = generate_import_with_mapping
|
||||||
|
first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
|
||||||
|
|
||||||
|
assert_equal Date.new(2020, 1, 1), first.spent_on
|
||||||
|
assert_equal Date.new(2020, 1, 2), second.spent_on
|
||||||
|
assert_equal Date.new(2020, 1, 3), third.spent_on
|
||||||
|
assert_equal Date.new(2020, 1, 4), fourth.spent_on
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_maps_hours
|
||||||
|
import = generate_import_with_mapping
|
||||||
|
first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
|
||||||
|
|
||||||
|
assert_equal 1, first.hours
|
||||||
|
assert_equal 2, second.hours
|
||||||
|
assert_equal 3, third.hours
|
||||||
|
assert_equal 4, fourth.hours
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_maps_comments
|
||||||
|
import = generate_import_with_mapping
|
||||||
|
first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
|
||||||
|
|
||||||
|
assert_equal 'Some Design', first.comments
|
||||||
|
assert_equal 'Some Development', second.comments
|
||||||
|
assert_equal 'Some QA', third.comments
|
||||||
|
assert_equal 'Some Inactivity', fourth.comments
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_maps_activity_to_column_value
|
||||||
|
import = generate_import_with_mapping
|
||||||
|
import.mapping.merge!('activity' => '5')
|
||||||
|
import.save!
|
||||||
|
|
||||||
|
# N.B. last row is not imported due to the usage of a disabled activity
|
||||||
|
first, second, third = new_records(TimeEntry, 3) { import.run }
|
||||||
|
|
||||||
|
assert_equal 9, first.activity_id
|
||||||
|
assert_equal 10, second.activity_id
|
||||||
|
assert_equal 11, third.activity_id
|
||||||
|
|
||||||
|
last = import.items.last
|
||||||
|
assert_equal 'Activity cannot be blank', last.message
|
||||||
|
assert_nil last.obj_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_maps_activity_to_fixed_value
|
||||||
|
import = generate_import_with_mapping
|
||||||
|
first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
|
||||||
|
|
||||||
|
assert_equal 10, first.activity_id
|
||||||
|
assert_equal 10, second.activity_id
|
||||||
|
assert_equal 10, third.activity_id
|
||||||
|
assert_equal 10, fourth.activity_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_maps_custom_fields
|
||||||
|
overtime_cf = CustomField.find(10)
|
||||||
|
|
||||||
|
import = generate_import_with_mapping
|
||||||
|
import.mapping.merge!('cf_10' => '6')
|
||||||
|
import.save!
|
||||||
|
first, second, third, fourth = new_records(TimeEntry, 4) { import.run }
|
||||||
|
|
||||||
|
assert_equal '1', first.custom_field_value(overtime_cf)
|
||||||
|
assert_equal '1', second.custom_field_value(overtime_cf)
|
||||||
|
assert_equal '0', third.custom_field_value(overtime_cf)
|
||||||
|
assert_equal '0', fourth.custom_field_value(overtime_cf)
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def generate_import(fixture_name='import_time_entries.csv')
|
||||||
|
import = TimeEntryImport.new
|
||||||
|
import.user_id = 2
|
||||||
|
import.file = uploaded_test_file(fixture_name, 'text/csv')
|
||||||
|
import.save!
|
||||||
|
import
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_import_with_mapping(fixture_name='import_time_entries.csv')
|
||||||
|
import = generate_import(fixture_name)
|
||||||
|
|
||||||
|
import.settings = {
|
||||||
|
'separator' => ';', 'wrapper' => '"', 'encoding' => 'UTF-8',
|
||||||
|
'mapping' => {
|
||||||
|
'project_id' => '1',
|
||||||
|
'activity' => 'value:10',
|
||||||
|
'issue_id' => '1',
|
||||||
|
'spent_on' => '2',
|
||||||
|
'hours' => '3',
|
||||||
|
'comments' => '4'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
import.save!
|
||||||
|
import
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
x
Reference in New Issue
Block a user