Patch by Gregor Schmidt. git-svn-id: http://svn.redmine.org/redmine/trunk@18146 e93f8b46-1217-0410-a6f0-8f06a7374b81tags/4.1.0
@@ -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 |
@@ -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> |
@@ -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 %> |
@@ -0,0 +1 @@ | |||
$('#fields-mapping').html('<%= escape_javascript(render :partial => 'time_entries_fields_mapping') %>'); |
@@ -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> |
@@ -0,0 +1,3 @@ | |||
<% content_for :sidebar do %> | |||
<%= render :partial => 'timelog/sidebar' %> | |||
<% end %> |
@@ -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) %> |
@@ -1,5 +1,5 @@ | |||
<div class="contextual"> | |||
<%= link_to l(:button_log_time), | |||
<%= link_to l(:button_log_time), | |||
_new_time_entry_path(@project, @query.filtered_issue_id), | |||
:class => 'icon icon-time-add' if User.current.allowed_to?(:log_time, @project, :global => true) %> | |||
</div> | |||
@@ -42,7 +42,7 @@ | |||
<% end %> | |||
<% content_for :sidebar do %> | |||
<%= render_sidebar_queries(TimeEntryQuery, @project) %> | |||
<%= render :partial => 'timelog/sidebar' %> | |||
<% end %> | |||
<% html_title(@query.new_record? ? l(:label_spent_time) : @query.name, l(:label_details)) %> |
@@ -78,7 +78,7 @@ | |||
<% end %> | |||
<% content_for :sidebar do %> | |||
<%= render_sidebar_queries(TimeEntryQuery, @project) %> | |||
<%= render :partial => 'sidebar' %> | |||
<% end %> | |||
<% 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_selected_roles_only: Only these roles | |||
label_import_issues: Import issues | |||
label_import_time_entries: Import time entries | |||
label_select_file_to_import: Select the file to import | |||
label_fields_separator: Field separator | |||
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 '/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' | |||
get '/imports/:id', :to => 'imports#show', :as => 'import' | |||
match '/imports/:id/settings', :to => 'imports#settings', :via => [:get, :post], :as => 'import_settings' |
@@ -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 |
@@ -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 |