Redmine importer の改造

この記事はRedmine Advent Calendar 2011の26日目の記事です。(もう28日になったけど)

本題に入る前に宣伝。

@tech_machii さんと共同で添付ファイルやアップロードしたファイルの全文検索機能の日本語対応化する
プラグインXapian_search_pluginの検索エンジンをHyper_Estraierへ替える
プラグインDMSFの検索エンジンをHyper_Estraierへ替える
の作業を進めています。

来年1月の品川Redmine勉強会(東京での Redmineの勉強会)で @tech_machii さんがこのことについて解説しますので興味がある方はご参加ください。

さて本題。

Redmine Advent Calendar 2011 の15日目の changeworldさんの記事
【15日目】Redmine の plugin に手を出すならまずこれから - Change The World
でも話題に出てきた CSV からのimport(インポート)ですが私も以前からいろいろといじっていました。
半年前までの成果をredmine importerで紹介したのですがその後もいじっていていろいろ強化したので改めて紹介します。

前回での主な変更点は

  • エラーが起きたときその原因も表示するようにした。(ただし最初のエラーだけ)
  • 日付型のカスタムフィールドには "YYYY-MM-DD"形式しか許されなかったので Date.parse がわかる形式(例えば "YYYY/MM/DD" や "YYYY/M/D" などいろいろ)ならOKにした。
  • カテゴリの find方法の間違いの修正。

でした。

今回追加した主な変更点は

  • 履歴の作成者と作成日をインポートできるようにした。
  • 履歴に関連する部分だけインポートできるようにした。(オリジナルでは題名とか説明なども上書きしていた)
  • 作成者/担当者の指定をログインID以外に姓名表示(「東京 太郎」とか「太郎 東京」とか)形式でもできるようにした。(管理者による「管理」=>「設定」=>「表示」=>「ユーザ名の表示書式」の設定に従います。姓と名の間はasciiスペース)
  • 指定された作成者がアカウント一覧から見つからないときエラーにすることもできるようにした。(オリジナルではエラーではなくインポート実行者のアカウントが作成者になる)
  • リスト型のカスタムフィールドに存在しない値がきたとき自動的に追加することができるようにした。(オリジナルではエラー)
  • Internal Server エラーにできるだけならないように事前にチェックを追加。
  • エラー原因/箇所がわかるようにエラーメッセージを詳細にした。

です。

これらの設定画面がこちらです。

ラベルの説明文がわかりにくいのはご容赦を。

IdlySphere版 redmine importer のバージョン0.9 からの差分は以下のとおりです。(半年前に紹介した差分も含んでいます)
Redmine-1.1系の場合にはこれ以外のパッチも必要なので半年前の記事を見てください。

--- redmine_importer.org/app/controllers/importer_controller.rb
+++ redmine_importer/app/controllers/importer_controller.rb
@@ -16,5 +16,5 @@
     :author, :description, :category, :priority, :tracker, :status,
     :start_date, :due_date, :done_ratio, :estimated_hours,
-    :parent_issue, :watchers ]
+    :parent_issue, :watchers, :created_on ]
   
   def index
@@ -43,4 +43,5 @@
     @samples = []
     
+  begin
     FasterCSV.new(iip.csv_data, {:headers=>true,
     :encoding=>iip.encoding, :quote_char=>iip.quote_char, :col_sep=>iip.col_sep}).each do |row|
@@ -52,7 +53,20 @@
       end
     end # do
+  rescue => e
+    msg = e.message + "\n" + e.backtrace.join("\n")
+    logger.debug msg
+    render :text => "CSV file read error: encoding error or " + e.message
+    return
+  end
     
     if @samples.size > 0
       @headers = @samples[0].headers
+
+      (0..@headers.size-1).each do |i|
+        unless @headers[i]
+          @headers[i] = '------'
+          flash[:warning] = "Column name empty error"
+        end
+      end
     end
     
@@ -89,5 +103,5 @@
     
     if issues.size > 1
-      flash[:warning] = "Unique field #{unique_attr}  with value '#{attr_value}' has duplicate record"
+      flash[:warning] = "Unique field #{unique_attr}#{unique_attr == @unique_attr ? '': '('+@unique_attr+')'} with value '#{attr_value}' has duplicate record"
       raise MultipleIssuesForUniqueValue, "Unique field #{unique_attr}  with value '#{attr_value}' has duplicate record"
     else
@@ -126,5 +140,8 @@
     update_issue = params[:update_issue]
     unique_field = params[:unique_field].empty? ? nil : params[:unique_field]
+    update_journal_only = params[:update_journal_only]
     journal_field = params[:journal_field]
+    journal_author = params[:journal_author]
+    journal_created_on = params[:journal_created_on]
     update_other_project = params[:update_other_project]
     ignore_non_exist = params[:ignore_non_exist]
@@ -133,5 +150,9 @@
     add_categories = params[:add_categories]
     add_versions = params[:add_versions]
+    add_custom_fields = params[:add_custom_fields]
+    use_user_name = params[:use_user_name]
+    @check_non_exist_user = params[:check_non_exist_user]
     unique_attr = fields_map[unique_field]
+    @unique_attr = unique_attr
 
     # attrs_map is fields_map's invert
@@ -165,7 +186,16 @@
     end
 
+    if use_user_name
+      @users = {}
+      User.find(:all).each do |u| @users[u.name] = u; end
+    end
+
     FasterCSV.new(iip.csv_data, {:headers=>true, :encoding=>iip.encoding, 
         :quote_char=>iip.quote_char, :col_sep=>iip.col_sep}).each do |row|
 
+      ["author", "assigned_to", "parent_issue"].each do |a|
+        row[attrs_map[a]] = nil if row[attrs_map[a]] == ''
+      end
+
       project = Project.find_by_name(row[attrs_map["project"]])
       if !project
@@ -174,13 +204,13 @@
       tracker = Tracker.find_by_name(row[attrs_map["tracker"]])
       status = IssueStatus.find_by_name(row[attrs_map["status"]])
-      author = attrs_map["author"] ? User.find_by_login(row[attrs_map["author"]]) : User.current
+      author = find_user('author', row[attrs_map["author"]], User.current)
       priority = Enumeration.find_by_name(row[attrs_map["priority"]])
       category_name = row[attrs_map["category"]]
-      category = IssueCategory.find_by_name(category_name)
+      category = IssueCategory.find_by_project_id_and_name(project.id, category_name)
       if (!category) && category_name && category_name.length > 0 && add_categories
         category = project.issue_categories.build(:name => category_name)
         category.save
       end
-      assigned_to = row[attrs_map["assigned_to"]] != nil ? User.find_by_login(row[attrs_map["assigned_to"]]) : nil
+      assigned_to = find_user('assigned_to', row[attrs_map["assigned_to"]]) if row[attrs_map["assigned_to"]]
       fixed_version_name = row[attrs_map["fixed_version"]]
       fixed_version = Version.find_by_name(fixed_version_name)
@@ -196,8 +226,10 @@
       issue.tracker_id = tracker != nil ? tracker.id : default_tracker
       issue.author_id = author != nil ? author.id : User.current.id
+      issue.created_on = issue.updated_on = row[attrs_map["created_on"]]
 
       if update_issue
         begin
           issue = issue_for_unique_attr(unique_attr,row[unique_field])
+          issue.instance_eval { @current_journal = nil }
           
           # ignore other project's issue or not
@@ -208,15 +240,16 @@
           
           # ignore closed issue except reopen
-          if issue.status.is_closed?
-            if status == nil || status.is_closed?
-              @skip_count += 1
-              next
-            end
-          end
+          #if issue.status.is_closed?
+          #  if status == nil || status.is_closed?
+          #    @skip_count += 1
+          #    next
+          #  end
+          #end
           
           # init journal
           note = row[journal_field] || ''
-          journal = issue.init_journal(author || User.current, 
-            note || '')
+          jauthor = find_user('journal author', row[journal_author], User.current)
+          journal = issue.init_journal(jauthor, note || '')
+          issue.updated_on = journal.created_on = row[journal_created_on]
             
           @update_count += 1
@@ -242,4 +275,5 @@
         @affect_projects_issues[project.name] += 1 : @affect_projects_issues[project.name] = 1
 
+if issue.new_record? || (update_issue && !update_journal_only)
       # required attributes
       issue.status_id = status != nil ? status.id : issue.status_id
@@ -276,4 +310,11 @@
       issue.custom_field_values = issue.available_custom_fields.inject({}) do |h, c|
         if value = row[attrs_map[c.name]]
+          if c.field_format == 'date'
+            org_value = value 
+            value = Date.parse(value).strftime("%Y-%m-%d") rescue value = org_value
+          elsif add_custom_fields && c.field_format == 'list' && !value.empty? && !c.possible_values.include?(value)
+            c.possible_values << value
+            c.save!
+          end
           h[c.id] = value
         end
@@ -285,5 +326,5 @@
         addable_watcher_users = issue.addable_watcher_users
         watchers.split(',').each do |watcher|
-          watcher_user = User.find_by_login(watcher)
+          watcher_user = find_user('watcher', watcher)
           if (!watcher_user) || (issue.watcher_users.include?(watcher_user))
             next
@@ -294,6 +335,18 @@
         end
       end
+end
+
+      begin
+        issue.save!
+        success = true
+      rescue => e
+        success = false
+        unless @failed_first_error
+          @failed_first_error = e.message
+        end
+      end
 
-      if (!issue.save)
+#      if (!issue.save)
+      if (!success)
         # &#35760;&#24405;&#38169;&#35823;
         @failed_count += 1
@@ -366,4 +419,16 @@
   end
   
+  def find_user(attr, name, default_user = nil)
+    if name
+      user = @users ? @users[name]: User.find_by_login(name)
+      if @check_non_exist_user && !user
+        flash[:warning] = "#{attr}: Unknown user '#{name}'"
+        user = ''
+      end
+    end
+    user = default_user unless user
+    user
+  end
+  
   def convert_string(string, encoding)
     nkf_option = 

--- redmine_importer.org/app/views/importer/match.html.erb
+++ redmine_importer/app/views/importer/match.html.erb
@@ -11,4 +11,5 @@
 </fieldset>
 -->
+
 <% form_tag({:action => 'result'}, {:multipart => true}) do %>
 	<%= hidden_field_tag 'project_id', @project.id %>
@@ -36,8 +37,14 @@
     <label><%= check_box_tag "add_categories", true, true %> <%= l(:label_importer_add_categories) %> </label><br/>		
     <label><%= check_box_tag "add_versions", true, true %> <%= l(:label_importer_add_versions) %> </label><br/>		
+    <label><%= check_box_tag "add_custom_fields", true, true %> <%= l(:label_importer_add_custom_fields) %> </label><br/>		
+    <label><%= check_box_tag "check_non_exist_user", true, false %> <%= l(:label_check_non_exist_user) %> </label><br/>
+    <label><%= check_box_tag "use_user_name", true, false %> <%= l(:label_use_user_name) %> </label><br/>
 		<label><%= check_box_tag "update_issue", true, false %> <%= l(:label_update_issue) %>
 		</label><br/>
 		<%= observe_field("update_issue", :function => <<END_OF_STRING
+document.getElementById("update_journal_only").disabled = !element.checked;
 document.getElementById("journal_field").disabled = !element.checked;
+document.getElementById("journal_author").disabled = !element.checked;
+document.getElementById("journal_created_on").disabled = !element.checked;
 document.getElementById("update_other_project").disabled = !element.checked;
 document.getElementById("ignore_non_exist").disabled = !element.checked;
@@ -46,7 +53,15 @@
 		%>
 		
+		&nbsp;&nbsp;&nbsp;&nbsp;<label><%= check_box_tag "update_journal_only", true, false, {:disabled => true} %> <%= l(:label_update_journal_only) %></label><br/>
+
 		&nbsp;&nbsp;&nbsp;&nbsp;<label><%= l(:label_journal_field) %>
 		<%= select_tag "journal_field", "<option value=\"\">#{l(:option_ignore)}</option>" + options_for_select(@headers), {:disabled => true} %></label><br/>
 		
+                &nbsp;&nbsp;&nbsp;&nbsp;<label><%= l(:label_journal_author) %>
+                <%= select_tag "journal_author", "<option value=\"\">#{l(:option_ignore)}</option>" + options_for_select(@headers), {:disabled => true} %></label><br/>
+
+                &nbsp;&nbsp;&nbsp;&nbsp;<label><%= l(:label_journal_created_on) %>
+                <%= select_tag "journal_created_on", "<option value=\"\">#{l(:option_ignore)}</option>" + options_for_select(@headers), {:disabled => true} %></label><br/>
+
 		&nbsp;&nbsp;&nbsp;&nbsp;<label><%= check_box_tag "update_other_project", true, false, {:disabled => true} %>
 		<%= l(:label_update_other_project) %></label><br/>

--- redmine_importer.org/app/views/importer/result.html.erb
+++ redmine_importer/app/views/importer/result.html.erb
@@ -12,5 +12,5 @@
 
 <% if @failed_count > 0 %>
-<%= l(:label_result_failed, @failed_count) %>
+<%= l(:label_result_failed, :count => @failed_count, :error => @failed_first_error) %>
 <table class="list">
     <thead><tr>

--- redmine_importer.org/config/locales/ja.yml
+++ redmine_importer/config/locales/ja.yml
@@ -10,23 +10,29 @@
   label_load_rules: "保存されたルールを読み込む"
   label_toplines: "{{value}}の上の行を参考:"
-  label_match_columns: "コラムの対象のフィールド"
-  label_match_select: "コラム毎の対象のフィールドを選択:"
+  label_match_columns: "カラムの対象のフィールド"
+  label_match_select: "カラム毎の対象のフィールドを選択:"
   label_import_rule: "インポートのルール"
-  label_default_tracker: "トラッカーのディフォルト:"
-  label_update_issue: "存在があるチケットを更新:"
-  label_journal_field: "メモとして入るフィールドを選択:"
-  label_importer_send_emails: "通常にメールを転送"
+  label_default_tracker: "トラッカーのデフォルト:"
+  label_update_issue: "既存のチケットを更新する(上記の「チケットの特定に使うユニークなカラムを選択」も必ず設定すること)"
+  label_update_journal_only: "履歴関連フィールドのみ更新する"
+  label_journal_field: "履歴として使用するカラムを選択:"
+  label_journal_author: "履歴の作成者として使用するカラムを選択:"
+  label_journal_created_on: "履歴の作成日として使用するカラムを選択:"
+  label_importer_send_emails: "通知メールを送る"
   label_importer_add_categories: "新しいカテゴリなら自動的に追加"
   label_importer_add_versions: "新しいターゲットのバージョンなら自動的に追加"
-  label_unique_field: "一意な値がある欄を選択(存在がある更新または連携をインポートする際に必須):"
-  label_update_other_project: "他のプロジェクトのチケットでも更新"
-  label_ignore_non_exist: "チケットの存在がなくても無視"
-  label_rule_name: "ルール舞を入れる:"
+  label_importer_add_custom_fields: "新しいカスタムフィールドリスト値なら自動的に追加"
+  label_use_user_name: "ユーザの特定に名前を使う(デフォルトはログインID)"
+  label_check_non_exist_user: "存在しないユーザはエラーする"
+  label_unique_field: "チケットの特定に使うユニークなカラムを選択(既存のチケットの更新または連携のインポートの際に必須):"
+  label_update_other_project: "他プロジェクトのチケット更新を許可する"
+  label_ignore_non_exist: "存在しないチケットの指定を無視する"
+  label_rule_name: "ルール名を入力"
   
   label_import_result: "インポートの結果"
-  label_result_notice: "{{handle_count}}行整理された。{{success_count}}行がうまく行った。"
-  label_result_projects: "関われるプロジェクト:"
+  label_result_notice: "{{handle_count}}件のチケットを処理し、{{success_count}} 件のチケットをインポートしました。"
+  label_result_projects: "更新したプロジェクト:"
   label_result_issues: "{{count}}行"
-  label_result_failed: "{{count}}行が失敗:"
+  label_result_failed: "{{count}}行が失敗: 最初のエラー {{error}}"
   
   option_ignore: "無視"
@@ -36,2 +42,6 @@
   button_save_rules_and_submit: "マッチルールを保存して確認"
 
+  activerecord:
+    errors:
+      messages:
+        record_invalid: "Validationエラー %{errors}"