diff options
author | Go MAEDA <maeda@farend.jp> | 2019-05-09 08:05:37 +0000 |
---|---|---|
committer | Go MAEDA <maeda@farend.jp> | 2019-05-09 08:05:37 +0000 |
commit | 6fd9d9ed7321615c61feb4e69fd61bdbaaf72faa (patch) | |
tree | 0a33fae6399f98ac8403629ba7f28166867d4eeb | |
parent | b540046ed7084ba50f5ca280f3ffae0751af8142 (diff) | |
download | redmine-6fd9d9ed7321615c61feb4e69fd61bdbaaf72faa.tar.gz redmine-6fd9d9ed7321615c61feb4e69fd61bdbaaf72faa.zip |
Import time entries (#28234).
Patch by Gregor Schmidt.
git-svn-id: http://svn.redmine.org/redmine/trunk@18146 e93f8b46-1217-0410-a6f0-8f06a7374b81
-rw-r--r-- | app/models/time_entry_import.rb | 80 | ||||
-rw-r--r-- | app/views/imports/_time_entries_fields_mapping.html.erb | 41 | ||||
-rw-r--r-- | app/views/imports/_time_entries_mapping.html.erb | 18 | ||||
-rw-r--r-- | app/views/imports/_time_entries_mapping.js.erb | 1 | ||||
-rw-r--r-- | app/views/imports/_time_entries_saved_objects.html.erb | 24 | ||||
-rw-r--r-- | app/views/imports/_time_entries_sidebar.html.erb | 3 | ||||
-rw-r--r-- | app/views/timelog/_sidebar.html.erb | 10 | ||||
-rw-r--r-- | app/views/timelog/index.html.erb | 4 | ||||
-rw-r--r-- | app/views/timelog/report.html.erb | 2 | ||||
-rw-r--r-- | config/locales/en.yml | 1 | ||||
-rw-r--r-- | config/routes.rb | 1 | ||||
-rw-r--r-- | test/fixtures/files/import_time_entries.csv | 5 | ||||
-rw-r--r-- | test/unit/time_entry_import_test.rb | 136 |
13 files changed, 323 insertions, 3 deletions
diff --git a/app/models/time_entry_import.rb b/app/models/time_entry_import.rb new file mode 100644 index 000000000..434851606 --- /dev/null +++ b/app/models/time_entry_import.rb @@ -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 diff --git a/app/views/imports/_time_entries_fields_mapping.html.erb b/app/views/imports/_time_entries_fields_mapping.html.erb new file mode 100644 index 000000000..f480fae68 --- /dev/null +++ b/app/views/imports/_time_entries_fields_mapping.html.erb @@ -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> diff --git a/app/views/imports/_time_entries_mapping.html.erb b/app/views/imports/_time_entries_mapping.html.erb new file mode 100644 index 000000000..15bc7dd0d --- /dev/null +++ b/app/views/imports/_time_entries_mapping.html.erb @@ -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 %> diff --git a/app/views/imports/_time_entries_mapping.js.erb b/app/views/imports/_time_entries_mapping.js.erb new file mode 100644 index 000000000..1448fc1cb --- /dev/null +++ b/app/views/imports/_time_entries_mapping.js.erb @@ -0,0 +1 @@ +$('#fields-mapping').html('<%= escape_javascript(render :partial => 'time_entries_fields_mapping') %>'); diff --git a/app/views/imports/_time_entries_saved_objects.html.erb b/app/views/imports/_time_entries_saved_objects.html.erb new file mode 100644 index 000000000..0579536d7 --- /dev/null +++ b/app/views/imports/_time_entries_saved_objects.html.erb @@ -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> diff --git a/app/views/imports/_time_entries_sidebar.html.erb b/app/views/imports/_time_entries_sidebar.html.erb new file mode 100644 index 000000000..396e4abd9 --- /dev/null +++ b/app/views/imports/_time_entries_sidebar.html.erb @@ -0,0 +1,3 @@ +<% content_for :sidebar do %> + <%= render :partial => 'timelog/sidebar' %> +<% end %> diff --git a/app/views/timelog/_sidebar.html.erb b/app/views/timelog/_sidebar.html.erb new file mode 100644 index 000000000..f54a73f81 --- /dev/null +++ b/app/views/timelog/_sidebar.html.erb @@ -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) %> diff --git a/app/views/timelog/index.html.erb b/app/views/timelog/index.html.erb index 35e833efb..3c904f3ec 100644 --- a/app/views/timelog/index.html.erb +++ b/app/views/timelog/index.html.erb @@ -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)) %> diff --git a/app/views/timelog/report.html.erb b/app/views/timelog/report.html.erb index b5307c4af..ae64a891e 100644 --- a/app/views/timelog/report.html.erb +++ b/app/views/timelog/report.html.erb @@ -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)) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 50f75877a..2310d1eec 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 4c65f976b..3c1fd0256 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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' diff --git a/test/fixtures/files/import_time_entries.csv b/test/fixtures/files/import_time_entries.csv new file mode 100644 index 000000000..1ff8075ef --- /dev/null +++ b/test/fixtures/files/import_time_entries.csv @@ -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 diff --git a/test/unit/time_entry_import_test.rb b/test/unit/time_entry_import_test.rb new file mode 100644 index 000000000..bf662efcf --- /dev/null +++ b/test/unit/time_entry_import_test.rb @@ -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 |