]> source.dussan.org Git - redmine.git/commitdiff
Import time entries (#28234).
authorGo MAEDA <maeda@farend.jp>
Thu, 9 May 2019 08:05:37 +0000 (08:05 +0000)
committerGo MAEDA <maeda@farend.jp>
Thu, 9 May 2019 08:05:37 +0000 (08:05 +0000)
Patch by Gregor Schmidt.

git-svn-id: http://svn.redmine.org/redmine/trunk@18146 e93f8b46-1217-0410-a6f0-8f06a7374b81

13 files changed:
app/models/time_entry_import.rb [new file with mode: 0644]
app/views/imports/_time_entries_fields_mapping.html.erb [new file with mode: 0644]
app/views/imports/_time_entries_mapping.html.erb [new file with mode: 0644]
app/views/imports/_time_entries_mapping.js.erb [new file with mode: 0644]
app/views/imports/_time_entries_saved_objects.html.erb [new file with mode: 0644]
app/views/imports/_time_entries_sidebar.html.erb [new file with mode: 0644]
app/views/timelog/_sidebar.html.erb [new file with mode: 0644]
app/views/timelog/index.html.erb
app/views/timelog/report.html.erb
config/locales/en.yml
config/routes.rb
test/fixtures/files/import_time_entries.csv [new file with mode: 0644]
test/unit/time_entry_import_test.rb [new file with mode: 0644]

diff --git a/app/models/time_entry_import.rb b/app/models/time_entry_import.rb
new file mode 100644 (file)
index 0000000..4348516
--- /dev/null
@@ -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 (file)
index 0000000..f480fae
--- /dev/null
@@ -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 (file)
index 0000000..15bc7dd
--- /dev/null
@@ -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 (file)
index 0000000..1448fc1
--- /dev/null
@@ -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 (file)
index 0000000..0579536
--- /dev/null
@@ -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 (file)
index 0000000..396e4ab
--- /dev/null
@@ -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 (file)
index 0000000..f54a73f
--- /dev/null
@@ -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) %>
index 35e833efb8dd7ade643b07ad17475e7f7041783d..3c904f3eccefc9925fc6e6c3a56a4715af264c3e 100644 (file)
@@ -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)) %>
index b5307c4afc4b8201bc5ff86b7a00ec5882108d09..ae64a891e113b9cb884f1e2b9e329fcc00fd8541 100644 (file)
@@ -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)) %>
index 50f75877a119fab3e4a4ff44f02ac6a63bb8cd31..2310d1eec6f4e2ea6baee8d014a09970b952720a 100644 (file)
@@ -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
index 4c65f976b4c04c5a9c5f2cc92bd4b3dee3772e34..3c1fd025614c3689a204b572c2f1dcedd60a3efb 100644 (file)
@@ -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 (file)
index 0000000..1ff8075
--- /dev/null
@@ -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 (file)
index 0000000..bf662ef
--- /dev/null
@@ -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