ラベル チケット の投稿を表示しています。 すべての投稿を表示
ラベル チケット の投稿を表示しています。 すべての投稿を表示

チケット解決/終了時の日時自動記録

先日、Redmine 2.2.3 がリリースされた。
新機能の一つに、
Feature #824: Add “closed_on” issue field (storing time of last closing) & add it as a column and filter on the issue list.
があったが、私のRedmine(v.1.4系)では、以下のように実装していたので紹介しておく。(もうv.1系には新機能は実装されないだろうし)
本家のものと違って、チケットの終了ステータスだけでなく、解決ステータスでも日時をセットするようにしている。

もともと、この機能が必要だったのは、手っ取り早くバグ曲線を書くときに、(履歴ではなく)チケット自体に解決や完了の日付が入っていることが必要だったため。

<機能>

  • チケットのステータスが変更された場合に、解決時や終了時に、チケットにその日時がセットされる。
    (トリガー:チケット登録、更新、コピー先、移動、bulk更新(コピー,移動)SCM連携時)
  • CSV出力に項目(resolved_on, closed_on)を追加。
  • チケットフィルタで解決日:resolved_on, 完了日:closed_onが利用可能。

<Redmineでの設定>

  • 「管理 - チケットのステータス」の各ステータスの設定時に、
    ・「解決日」をセットするステータス → 「解決したチケット」にチェックを入れる
    ・「完了日」をセットするステータス → 「終了したチケット」にチェックを入れる

<Redmine-1.4.4からのコード修正内容>

チケットのテンプレート機能

多くのプロダクトを扱う組織では、当然Redmine上のプロジェクトも多くなる。そうなると、Redmineで管理したい項目もプロジェクトによって、いろいろ異なってくるものだ。(もちろん組織標準を決めて、プロジェクトがそれに従うという文化もあるかもしれないけど。普通はそう簡単にはいかないでしょう。)

Redmineでは、カスタムフィールドを追加し、プロジェクトごとに採用するカスタムフィールドを決めることは、比較的容易にできる。けれど、だからといっていちいち様々なプロジェクトの要望通りにカスタムフィールドを追加していたのではきりがないし、似たフィールドや、意味不明なもの、プロジェクトの気が変わって使われないものまで出てくることでしょう。

なので、カスタムフィールドは、すべてのプロジェクトで共通に使うようなものやプロジェクトデータの計測に有益なものに、本当に最小限にとどめて、それ以外はプロジェクトの必要に応じてプロジェクトの管理下で定義するのが良い落としどころとなる。

これは、チケット編集時に説明欄にテンプレートを表示する機能を付加し、かつ、そのテンプレートがプロジェクトごとにカスタマイズ可能になっていることで実現できる。


<機能>

  • プロジェクトごと、トラッカーごとに、チケット編集時に表示するテンプレートをカスタマイズできるようにする。
  • 「Tenplate-トラッカー名」の名称でカスタムフィールド(プロジェクト)がある時に、その値をそのプロジェクトのトラッカーのテンプレートとする。

<Redmineでの設定>

  • カスタムフィールド(プロジェクト)に「Tenplate-トラッカー名」の名称でカスタムフィールドを作成する。

<Redmine-1.4.4からのコード修正内容>

チケットのカスタムフィールドのステータス別必須入力チェック v.1.4.4版

redmine-2.1.0がリリースされ、新機能の一つとして、「ステータス別のチケット項目の必須/リードオンリー設定」がある。
似たような機能(「チケットのカスタムフィールドをステータス別に必須入力チェックする」)について、以前、redmine-1.3.0への適用として紹介した。
redmine-1.4系では実装される予定は無いと思うので、ここで改めて紹介しておく。

redmine-2.1.0の機能との違いは次の通り。
  • Redmineシステムとしてデフォルトの設定を行い、プロジェクト毎にカスタマイズする使い方を想定。
  • プロジェクト毎に、必須項目を設定できる。
  • カスタムフィールドに対してしか設定できない。
  • リードオンリーの設定はない。
  • ロール別の設定はない。

<Redmineでの設定>

  • データベースのmigrateが必要。(rake db:migrate RAILS_ENV="production" を実行)
  • Redmineシステムとしてのデフォルト設定を行う。
    「管理」→「カスタムフィールド」
    各カスタムフィールドの編集画面の、「必須」の項目で、そのカスタムフィールドについてデフォルトで必須にするステータスにチェックを入れる。
    「すべて」をチェックすると全てのステータスで必須となり、全プロジェクトで必須となる(従来と同じ挙動)。
    「すべて」のチェックを外すと、プロジェクト毎に必須項目を設定できるようになる。
  • プロジェクトメニューの「設定」→「情報」の「必須」で、
    ステータス別に必須にするカスタムフィールドにチェックを入れる。

<Redmine-1.4.4からのコード修正内容>


チケット一覧に作業時間の記録を表示する

チケット一覧に出せそうで出ていない項目の一つに「作業時間の記録」がある。
Redmineでは一つのチケットに費やした時間を「活動」とセットで積み上げていく計測方法になる。
これはDB的にみれば、1つのチケットに複数の時間レコードを追加していくことだ。
つまり、チケット(isues)と時間(time_entries)とで格納先テーブルが異なり、このため、チケット一覧に表示しない仕様となっているようだ。

もちろん、時間については「作業時間の記録」として詳細やレポート画面に表示できる(月別等の時間を表示するには、この画面が便利)。
ただ、最もよく使うチケット一覧(カスタムクエリー)で、単純にその時点で積み上げられた時間を確認したいこともある。
また、チケットの他属性項目含めたCSV出力でも、チケット一覧の方が使い勝手が良い。

そこで以下の改造。

<Redmineソースの改造>(v.1.3.0ベース)
  • チケット一覧に「時間」のチケット毎の合計値を表示させる。
  • また、その時間をリンクにして、「作業時間の記録」の詳細画面へリンクさせる。
  • ただし、ソートやフィルターへは未対応。
     when 'Fixnum', 'Float'
       if column.name == :done_ratio
         progress_bar(value, :width => '80px')
+      elsif column.name == :spent_hours
+        link_to(("%.2f" % issue.spent_hours.to_f), {:controller => 'timelog', :action => 'index', :project_id => @project, :issue_id => issue}) if issue.spent_hours > 0
       else
         h(value.to_s)
       end
     QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
     QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
     QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
+    QueryColumn.new(:spent_hours),
     QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
     QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
   field_redirect_existing_links: Redirect existing links
   field_estimated_hours: Estimated time
+  field_spent_hours: Spent time
   field_column_names: Columns
   field_time_entries: Log time
他言語も必要に応じて適宜修正。

チケットのカスタムフィールドをステータス別に必須入力チェックする

Redmineでは、チケットの属性として任意の入力項目を「カスタムフィールド」として追加することができる。選択リスト形式や文字列、数字など、様々なフォーマットを指定したり必須にするかどうかなども設定でき、また、どのトラッカーで利用するのかも設定できる。かなりなことは、設定できるが、今一つ足りないのが、ステータス別に必須にするかどうかを設定できないことだ。

例えば、(企業組織ではPDCAを回すために、)バグ管理票なるもので、そのバグの原因や修正区分、そのバグの混入工程や発見工程なんかの属性を管理することがある。これをチケットで管理する場合、カスタムフィールドでこれらの属性を定義することになる。そこで問題となるのが、Redmineのカスタムフィールドで「必須」にするとどのステータスにおいても必須になってしまう仕様だ。

Redmineの場合、必須チェックはステータスと連動させることができない。例えば前述の「原因」項目の場合、チケット登録時(バグ発見時)には不明な場合がほとんどで選択できない。ただ、チケット入力時には不要な情報でも、クローズする時には必須にしたい項目でもある。

このような、プロジェクト管理要素の強い属性は、チケットを処理している時には、(エンジニアにとってあまり重要に感じられないこともあり、)必須にしないと選択し忘れたり、後で入れようと思ったりされてしまう。ただ、数千ものバグを抱えるようなプロジェクトでは、前述の属性を後でまとめて入力するのは不可能に近い。やはりこれらは遅くともチケットクローズ時に確実に付加させてておくべき情報だ。

そのためには、ステータス別にカスタムフィールドの入力チェックをでききるようにしなければならない。以下がその改造。

<Redmineでの設定>
  • データベースマイグレートが必要。(rake db:migrate RAILS_ENV="production")
  • 管理→カスタムフィールで、チケットのカスタムフィールドに対し、デフォルト(プロジェクト登録時)で必須にするかどうかを設定する。プロジェクト登録時に必須/任意の設定は可能。
  • プロジェクトメニューの設定→概要 で、ステータス別に必須にするカスタムフィールドにチェックを入れることで、必須設定する。

<Redmineソースの改造>(v.1.3.0ベース)
  • 管理→カスタムフィールド の設定画面に、デフォルトで必須にするステータスを選択するチェックボックスを表示する。
  • プロジェクトメニューの設定→概要 の画面に、チケットのカスタムフィールド毎に、ステータス別に必須/任意設定するチェックボックスを表示する。
  • チケット登録・更新時に、ステータスに応じて必須設定されたカスタムフィールドの入力チェックを行う。
  • custom_fieldsテーブルに、デフォルトで入力必須にするかどうかを管理するカラムを追加。
  • プロジェクト別にステータス別必須カスタムフィールドを管理する新規テーブルを作成。
   end

   def create
+    return unless validate_required_fields(@issue.status.id)
     call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
     if @issue.save
       attachments = Attachment.attach_files(@issue, params[:attachments])
       call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
:
:
   def update
     update_issue_from_params
+    return unless validate_required_fields(params[:issue][:status_id].to_i)
     if @issue.save_issue_with_child_records(params, @time_entry)
       render_attachment_warning_if_needed(@issue)
       flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?


     attributes[:custom_field_values].reject! {|k,v| v.blank?} if attributes[:custom_field_values]
     attributes
   end
:
:
+  def validate_required_fields(issue_status)
+    if params.nil? || params.empty? || ((params.include? 'issue') == false) || ((params[:issue].include? 'custom_field_values') == false)
+      return true
+    end
+    required_custom_fields = get_required_custom_field
+    issue_custom_fields = IssueCustomField.find(:all)
+
+    #check issue_status.to_s if this is required_custom_fields's Hash key or not.
+    tmpkeys = required_custom_fields.keys
+    tmpflg = 0
+    for i in 0 .. tmpkeys.length-1
+      if tmpkeys[i] == issue_status.to_s
+        tmpflg = 1
+        break
+      end
+    end
+    if tmpflg == 0
+      # issue_status.to_s is not Hash key
+      return true
+    end
+
+    if required_custom_fields[issue_status.to_s].size == 0 || issue_custom_fields == nil
+      return true
+    end
+    required_custom_fields[issue_status.to_s].each do |required|
+      issue_custom_fields.each do |issue_custom_field|
+        if required == issue_custom_field.name
+          unless (params[:issue][:custom_field_values].include? issue_custom_field.id.to_s)
+            break
+          end
+          if params[:issue][:custom_field_values][issue_custom_field.id.to_s] == ""
+            tmp = t 'activerecord.errors.messages.blank'
+            flash.now[:error] = required + " " + tmp
+            @priorities = IssuePriority.all
+            if @action_name == "update"
+              render :template => 'issues/edit', :layout => !request.xhr?
+            elsif @action_name == "create"
+              render :template => 'issues/new', :layout => !request.xhr?
+            end
+            return false
+          end
+        end
+      end
+    end
+    return true
+  end

 end
     @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
     @trackers = Tracker.all
     @project = Project.new(params[:project])
+    @required_custom_field = get_required_custom_field
   end

   verify :method => :post, :only => :create, :render => {:nothing => true, :status => :method_not_allowed }
:
:
                                   :conditions => "parent_id IS NULL AND status = #{Project::STATUS_ACTIVE}",
                                   :order => 'name')
     @source_project = Project.find(params[:id])
+    @issue_status = IssueStatus.find( :all ,:order => 'position')
+    @required_custom_field = get_required_custom_field
     if request.get?
       @project = Project.copy_from(@source_project)
       if @project
:
:
         @project.safe_attributes = params[:project]
         if validate_parent_id && @project.copy(@source_project, :only => params[:only])
           @project.set_allowed_parent!(params[:project]['parent_id']) if params[:project].has_key?('parent_id')
+          save_required_custom_field
           flash[:notice] = l(:notice_successful_create)
           redirect_to :controller => 'projects', :action => 'settings', :id => @project
         elsif !@project.new_record?
:
:
     @trackers = Tracker.all
     @repository ||= @project.repository
     @wiki ||= @project.wiki
+    @required_custom_field = get_required_custom_field
   end

   def edit
:
:
       @project.set_allowed_parent!(params[:project]['parent_id']) if params[:project].has_key?('parent_id')
       respond_to do |format|
         format.html {
+          save_required_custom_field
           flash[:notice] = l(:notice_successful_update)
           redirect_to :action => 'settings', :id => @project
         }
:
:
     end
     true
   end
+
+  def save_required_custom_field
+    hash_others = Hash.new
+    tmp_array = []
+    hash_others = {}
+    @issue_status = IssueStatus.find( :all ,:order => 'position')
+    for tmp_issue_status in @issue_status
+      tmp_array = []
+      if params[:required_custom_field] != nil
+        if params[:required_custom_field].include? tmp_issue_status.id.to_s
+          tmp_array = params[:required_custom_field][tmp_issue_status.id.to_s].keys
+        end
+      end
+      hash_others[tmp_issue_status.id.to_s] = tmp_array
+    end
+
+    required_custom_field = RequiredCustomField.find(:first, :conditions => ["project_id = ?", @project.id])
+    if required_custom_field == nil
+      required_custom_field = RequiredCustomField.new(:project_id => @project.id,
+                                                      :others => hash_others)
+    else
+      required_custom_field.others = hash_others
+    end
+    if required_custom_field.save == false
+      required_custom_field.connection.rollback_db_transaction
+    end
+  end
+
 end
     sharing = 'none' unless Version::VERSION_SHARINGS.include?(sharing)
     l("label_version_sharing_#{sharing}")
   end
+
+  def required_custom_field_checkbox_tag(project, custom_field)
+    s = ''
+    @issue_status.each do |status|
+      custom_field.is_required ? checked = true : checked = false
+      unless @required_custom_field[status.id.to_s] == nil
+        @required_custom_field[status.id.to_s].each do |cf|
+          checked = true if cf == custom_field.name
+        end
+      end
+      s << "\n"
+      s << check_box_tag('required_custom_field[' + status.id.to_s + '][' + custom_field.name + ']',
+                         custom_field.id,
+                         checked,
+                         :disabled => custom_field.is_required?) + "\n\n"
+    end
+    s
+  end
+
+  def get_required_custom_field
+    required_custom_field = RequiredCustomField.find(:first, :conditions => ["project_id = ?", @project.id])
+    @issue_status = IssueStatus.find( :all ,:order => 'position')
+    required_custom_field_other = Hash.new
+    if required_custom_field.nil?
+      # Registering new project, set default required custom feilds.
+      @issue_status.each do |status|
+        cfnames = []
+        @issue_custom_fields.each do |cf|
+            cfnames << cf.name if !cf.required_issue_status_ids.nil? && YAML.load(cf.required_issue_status_ids).include?(status.id.to_s)
+          end
+        required_custom_field_other[status.id.to_s] = cfnames
+      end
+    else
+      # On existing project's setting
+      @issue_status.each do |status|
+        required_custom_field_other[status.id.to_s] = required_custom_field[status.id.to_s]
+      end
+    end
+    required_custom_field_other
+  end
 end
+class RequiredCustomField < ActiveRecord::Base
+  serialize :others
+  def [](attr_name)
+    if attribute_present? attr_name
+      super
+    else
+      others ? others[attr_name] : nil
+    end
+  end
+end
     <% end %>
     <%= hidden_field_tag "custom_field[tracker_ids][]", '' %>
     </fieldset>
     &nbsp;
-    <p><%= f.check_box :is_required %></p>
+    <p><%= f.check_box :is_required %>
+    <%= l(:label_all) %>: <i><%= l(:text_required_all) %></i><br />
+    <%=l(:label_applied_status)%>: <i><%= l(:text_required_specific) %></i><br />
+    <% for status in IssueStatus.find(:all ,:order => 'position') -%>
+        <%= check_box_tag "custom_field[required_issue_status_ids][]",
+            status.id.to_s,
+            (YAML.load(@custom_field.required_issue_status_ids).include?(status.id.to_s) unless @custom_field.required_issue_status_ids.nil?),
+            :onchange => 'if (this.checked){$("custom_field_is_required").checked = false;}'
+        %>
+        <%= h(status.name) %><br />
+    <% end -%>
+    </p>
+    &nbsp;
     <p><%= f.check_box :is_for_all %></p>
     <p><%= f.check_box :is_filter %></p>
     <p><%= f.check_box :searchable %></p>
 </fieldset>
 <% end %>
 <% end %>
+
+<fieldset class="box"><legend><%=l(:field_is_required)%></legend>
+  <table class="list">
+    <thead>
+      <tr>
+        <th><%=l(:label_custom_field)%></th>
+        <% for tmp_issue_status in @issue_status %>
+        <th><%=tmp_issue_status%></th>
+        <% end %>
+      </tr>
+    </thead>
+    <tbody>
+      <% @issue_custom_fields.each do |custom_field| %>
+        <tr class="<%= cycle("odd", "even") %>">
+          <td class="name" align="left"><%= custom_field.name %></td>
+          <%= required_custom_field_checkbox_tag(@project, custom_field) %>
+        </tr>
+      <% end %>
+    </tbody>
+  </table>
+</fieldset>
+
 <!--[eoform:project]-->
   text_scm_command_version: Version
   text_scm_config: You can configure your scm commands in config/configuration.yml. Please restart the application after editing it.
   text_scm_command_not_available: Scm command is not available. Please check settings on the administration panel.
+  text_required_all: "Will be required field in all satatuses, and be unchangeable from requierd field in the project setting."
+  text_required_specific: "Will be default required field in checked statuses, and be changeable for requierd or not in the project setting."

   default_role_manager: Manager
   default_role_developer: Developer
   text_scm_command_version: バージョン
   text_scm_config: バージョン管理システムのコマンドをconfig/configuration.ymlで設定できます。設定後、Redmineを再起動してください。
   text_scm_command_not_available: バージョン管理システムのコマンドが利用できません。管理画面にて設定を確認してください。
+  text_required_all: "すべてのステータスで必須項目とし、プロジェクトの設定では変更できないようになります。"
+  text_required_specific: "チェックしたステータスをデフォルトの必須項目とし、プロジェクトの設定で変更できるようになります。"

   default_role_manager: 管理者
   default_role_developer: 開発者
+class AddCustomFieldsRequiredIssueStatusIds < ActiveRecord::Migration
+  def self.up
+    add_column :custom_fields, :required_issue_status_ids, :text
+  end
+
+  def self.down
+    remove_column :custom_fields, :required_issue_status_ids
+  end
+end
+class CreateRequiredCustomFields < ActiveRecord::Migration
+  def self.up 
+    create_table :required_custom_fields do |t|
+      t.column "project_id", :integer, :default => 0, :null => false
+      t.column "others", :text
+    end
+    add_index :required_custom_fields, :project_id
+  end
+
+  def self.down
+    drop_table :required_custom_fields
+  end
+end

プロジェクト間チケットコピー機能の改造で縦割り組織のサイロ化打破?

Redmine上のプロジェクトの粒度とも関連するが、複数のプロジェクト間で情報共有しなければならないケースで、必須となるのがチケットのコピー機能だ。

ソフトウエア開発では(いや、ソフト開発に限らないかもしれないが)、顧客への「製品・サービス」や「展開品・派生品」を管理するグループ(縦軸)と、機能・コンポーネント等の単位で開発を行うグループ(横軸)で構成される、いわゆるマトリックス体制を採ることが多いと思う。多製品・サービスになればなるほど、開発部隊は開発対象をプラットフォーム化・共通化し、製品やサービスに展開することで開発効率を上げるためだ。このような組織で、プロジェクト管理ツールを使うとなると、ツール上の「プロジェクト」の粒度をよく考える必要が出てくる。

縦軸や横軸を形成するグループが複数存在し、各々が役割分担している場合、それぞれプロジェクトを分ける方が良いだろう。なぜなら、Redmineの場合、プロジェクトが分かれても、プロジェクト間でチケットのコピーを極簡単に行える(後には、Versionのプロジェクト間共有も可能となった)からだ。

そして、それだからこそ、プロジェクト間の依頼事項はチケットのコピーによって行うこととなる。チケットのコピーは、重複を生むと考えられがちだが、それはちょっと違う。グループ間でコピーされたチケットは、そもそものチケットのゴール(CLOSE条件)が違うからだ。横軸はバグ修正や機能の実装が完了すれば、そのチケットはCLOSEする。縦軸はそれらの案件を顧客にリリースすることでCLOSEすることになる。平行開発する派生品があれば、派生品毎にそれぞれCLOSEすべきタイミングがが異なることもザラだ。CLOSEするタイミングが異なる案件を一つのチケットで管理しようとする方が、かえって無理というものだ。 自分の組織では、各グループの独立性が高く、(というかグループ間の見えない壁みたいなモノで、)いわゆるサイロ化現象を起こしていた。だからこそ、一つのチケットが複数のグループで各々の責任範囲を持つとき、コピーすることで案件を分割するこの考え方が、非常にしっくりきたようだ。(またこの運用が、組織的なRedmine利用の原動力ともなった。)
もっとも、複数のグループを一つの「プロジェクト」で全て包含し、その中で「Version」(マイルストーン)を複数立てて管理することも可能であろうが、これでは各々のグループの独立性が高いほど(マトリックス開発の役割分担がはっきりしていればいるほど)、運用は困難になるだろう。

ただ、Redmineのコピーの機能で、条規の運用を行おうとしたとき、何かかゆいところに手が届かない感じがした。
  • チケット登録権限のないプロジェクトへもチケットをコピーできてしまう。
  • プロジェクト間でチケットをコピーしたことが、その時点で判らない。
  • コピーしたら、各々のチケットの進捗が判るよう、チケット関連付けをするルールにしたものの、ちょっと面倒。忘れることもしばしば。
  • 状況によっては、チケットに付随するウォッチャーもコピー先へ反映させたいことも多い。
これらを改善すべく、以下の改造を施した。


<プロジェクト間チケットコピー・移動の運用ルール>
  • チケットを取りに行くPull型がお勧め。関連するプロジェクトのチケットを参照し、他のプロジェクトで自分のプロジェクトに関するチケットが発生したら、自分のプロジェクトへコピーするという原則。プロジェクト間の最初の伝達は定例会議や口頭(生のコミュニケーションも大事)、チケットのウォッチャー指定など様々。
  • コピー・移動先のプロジェクトで、チケット登録することができ、チケットをアサインすることのできる立場の人なら、Push型でも良いが、そうでない場合無視されるチケットが出たりしかねない。(そういう意味でチケットをPushできる人を運用上決める必要性が出たり、余計なアクションが増えてしまうので、しっくりこなかった。)

<Redmineでの設定>
  • 管理-設定-チケットトラッキングで、「異なるプロジェクトのチケット間で関係の設定を許可」にチェックする。
  • 管理-ロールの設定で、Non-memberに「チケットの参照」「チケットの移動」の権限を与える。(Non-memberの場合、これをしないとコピー・移動元のチケット詳細画面を開いたり、「コピー・移動」のリンクが表示されない) ⇒この場合、自分がチケット登録の権限がないプロジェクトも、コピー・移動先プロジェクトを選択できてしまう(バグ?仕様?)ので、以下の修正を行う。

<Redmineソースの改造>(v.1.3.0ベース)
  • コピー・移動先に選択できるプロジェクトを権限に応じて絞る(チケット登録の権限のあるプロジェクト、かつ、チケット機能を使っているプロジェクト)
       # admin is allowed to move issues to any active (visible) project
       projects = Project.visible.all
     elsif User.current.logged?
-      if Role.non_member.allowed_to?(:move_issues)
-        projects = Project.visible.all
-      else
-        User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
-      end
+      # the projects belonging to as member.
+      User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:add_issues)} && m.project.module_enabled?('issue_tracking')}
     end
     projects
   end
  • コピー・移動先に指定できるプロジェクトが無い(チケット登録権限のあるプロジェクトが無い)場合にコピーできないようにする。
 <% if @copy %>

   <%= hidden_field_tag("copy_options[copy]", "1") %>
-  <%= submit_tag l(:button_copy) %>
-  <%= submit_tag l(:button_copy_and_follow), :name => 'follow' %>
+  <%= submit_tag l(:button_copy), :disabled => @allowed_projects.blank? %>
+  <%= submit_tag l(:button_copy_and_follow), :name => 'follow', :disabled => @allowed_projects.blank? %>
 <% else %>
-  <%= submit_tag l(:button_move) %>
-  <%= submit_tag l(:button_move_and_follow), :name => 'follow' %>
+  <%= submit_tag l(:button_move), :disabled => @allowed_projects.blank? %>
+  <%= submit_tag l(:button_move_and_follow), :name => 'follow', :disabled => @allowed_projects.blank? %>
 <% end %>
 <% end %>
  • コピー時に、
    ・ウォッチャーもコピーするか選択できるようにする。
    ・履歴を残す。
    ・自動的に関連付けする。
       end
     when 'attachment'
       label = l(:label_attachment)
+    when 'cp'
+      label = l(:label_issue)
+      ni = Issue.find_by_id(detail.value) and value = ni.project.to_s + " - #" + ni.id.to_s if detail.value
+      oi = Issue.find_by_id(detail.old_value) and old_value = oi.project.to_s + " - #" + oi.id.to_s if detail.old_value
     end
     call_hook(:helper_issues_show_detail_after_setting, {:detail => detail, :label => label, :value => value, :old_value => old_value })
:
:
       if detail.property == 'attachment' && !value.blank? && a = Attachment.find_by_id(detail.prop_key)
         # Link to the attachment if it has not been removed
         value = link_to_attachment(a)
+      elsif detail.property == 'cp'
+        if ni == nil
+          value = "#" + detail.value.to_s
+        else
+          value = link_to_issue(ni, :subject => false, :project => true) and value = content_tag("i", value)
+        end
+        if oi == nil
+          old_value = "#" + detail.old_value.to_s
+        else
+          old_value = link_to_issue(oi, :subject => false, :project => true) and old_value = content_tag("i", old_value)
+        end
       else
         value = content_tag("i", h(value)) if value
       end
:
:
         end
       when 'attachment'
         l(:text_journal_added, :label => label, :value => value)
+      when 'cp'
+        l(:text_journal_copied, :label => label, :old => old_value, :new => value)
       end
     else
         call_hook(:controller_issues_move_before_save, { :params => params, :issue => issue, :target_project => @target_project, :copy => !!@copy })
         if r = issue.move_to_project(@target_project, new_tracker, {:copy => @copy, :attributes => extract_changed_attributes_for_move(params)})
           moved_issues << r
+
+          if @copy
+            relation = IssueRelation.new(:issue_from => r,
+                                         :issue_to => issue,
+                                         :relation_type => IssueRelation::TYPE_RELATES)
+            if relation.save == false
+              relation.connection.rollback_db_transaction
+            end
+
+            if params[:copy_options][:copy_watchers]
+              allowed_watchers = Watcher.find(:all, :conditions => ["watchable_type like ? and watchable_id = ?", issue.class.to_s.underscore, issue.id])
+              for allowed_watcher in allowed_watchers
+                if r.project_id != issue.project_id && Issue.visible(User.find(allowed_watcher.user_id)).find_by_id(r.id) == nil
+                  next
+                end
+                new_watcher = Watcher.new(:watchable_type => "Issue",
+                                          :watchable_id => r.id,
+                                          :user_id => allowed_watcher.user_id)
+                if new_watcher.save == false
+                  new_watcher.connection.rollback_db_transaction
+                end
+              end
+            end
+
+            journal_target = r.init_journal(User.current)
+            journal_target.details << JournalDetail.new(:property => 'cp',
+                                                        :prop_key => 'issue_id',
+                                                        :old_value => issue.id,
+                                                        :value => r.id)
+            journal_allowed = issue.init_journal(User.current)
+            journal_allowed.details << JournalDetail.new(:property => 'cp',
+                                                         :prop_key => 'issue_id',
+                                                         :old_value => issue.id,
+                                                         :value => r.id)
+            if journal_target.save
+              if  journal_allowed.save == false
+                journal_target.connection.rollback_db_transaction
+                journal_allowed.connection.rollback_db_transaction
+              end
+            else
+              journal_target.connection.rollback_db_transaction
+            end
+          end
+
         else
           unsaved_issue_ids << issue.id
         end
   text_journal_set_to: "%{label} set to %{value}"
   text_journal_deleted: "%{label} deleted (%{old})"
   text_journal_added: "%{label} %{value} added"
+  text_journal_copied: "%{label} copied from %{old} to %{new}"
+  text_copy_watchers: Also copy watchers.
   text_tip_issue_begin_day: issue beginning this day
   text_tip_issue_end_day: issue ending this day
   text_tip_issue_begin_end_day: issue beginning and ending this day
   text_journal_set_to: "%{label} を %{value} にセット"
   text_journal_deleted: "%{label} を削除 (%{old})"
   text_journal_added: "%{label} %{value} を追加"
+  text_journal_copied: "%{label} を %{old} から %{new} にコピー"
+  text_copy_watchers: ウォッチャーもコピーする
   text_tip_issue_begin_day: この日に開始するタスク
   text_tip_issue_end_day: この日に終了するタスク
   text_tip_issue_begin_end_day: この日のうちに開始して終了するタスク

 <p>
   <label for='status_id'><%= l(:field_status) %></label>
+  <% if @copy %>
+    <%= select_tag('status_id', "<option value=\"#{IssueStatus.default.id}\">#{IssueStatus.default.name}</option>" + options_from_collection_for_select(@available_statuses, :id, :name)) %>
+  <% else %>
   <%= select_tag('status_id', "<option value=\"\">#{l(:label_no_change_option)}</option>" + options_from_collection_for_select(@available_statuses, :id, :name)) %>
+  <% end %>
 </p>

 <p>
:
:
 </p>
 </div>

+<% if @copy %>
+<p>
+  <%= check_box_tag "copy_options[copy_watchers]", "1" %>
+  <%= content_tag('b', l(:text_copy_watchers)) %>
+</p>
+<% end %>
+
 </fieldset>

 <fieldset><legend><%= l(:field_notes) %></legend>

チケットCSV出力に関連チケット・注記・関連コミットを追加する

大人数でRedmineを使っていると、エクセル大好きな人もいると思う。
全てをCSVに出して、加工し、レポートを作成する。
それが良いか悪いかは別として、RedmineのCSV出力でちょっと足りない項目を付け足してみた。
「注記」については200コメントまで出力するように制限した。(CSVのカラム数制限のある旧エクセル対策として)


<Redmineでの設定>
  • 「関係しているリビジョン」を出すには、関連するリポジトリのあるプロジェクトで、リポジトリ参照権限があること。

<Redmineソースの改造>(v.1.3.0ベース)
  • 「関連するチケット」「注記」「関係しているリビジョン」を出力するCSVに追加できるようにする改造。

     export = FCSV.generate(:col_sep => l(:general_csv_separator)) do |csv|
       # csv header fields
       csv << [ "#" ] + columns.collect {|c| Redmine::CodesetUtil.from_utf8(c.caption.to_s, encoding) } +
-        (options[:description] ? [Redmine::CodesetUtil.from_utf8(l(:field_description), encoding)] : [])
+        (options[:issue_to] ? [Redmine::CodesetUtil.from_utf8(l(:field_issue_to)+"-from", encoding)] : []) +
+        (options[:issue_to] ? [Redmine::CodesetUtil.from_utf8(l(:field_issue_to)+"-to", encoding)] : []) +
+        (options[:revision] ? [Redmine::CodesetUtil.from_utf8(l(:label_associated_revisions), encoding)] : []) +
+        (options[:description] ? [Redmine::CodesetUtil.from_utf8(l(:field_description), encoding)] : []) +
+        (options[:notes] ? [Redmine::CodesetUtil.from_utf8(l(:field_notes), encoding)] : [])

       # csv lines
       issues.each do |issue|
:
:
           end
           s.to_s
         end
+
+        if options[:issue_to]
+          related_from = []
+          issue.relations_from.each do |relation|
+            related_from << Redmine::CodesetUtil.from_utf8("#{relation.relation_type}:#{relation.issue_to_id.to_s}", encoding)
+          end
+          related_from = related_from.join("\x0D\x0A")
+
+          related_to = []
+          issue.relations_to.each do |relation|
+            related_to << Redmine::CodesetUtil.from_utf8("#{relation.relation_type}:#{relation.issue_from_id.to_s}", encoding)
+          end
+          related_to = related_to.join("\x0D\x0A")
+        end
+
+        if options[:revision]
+          revision = []
+          issue.changesets.each do |c|
+            revision << Redmine::CodesetUtil.from_utf8(c.revision, encoding)
+          end
+          revision = revision.join("\x0D\x0A")
+        end
+
+        if options[:notes]
+          notes = []
+          Journal.find(:all,
+                       :conditions => ["journalized_id= ?", issue.id],
+                       :order => :created_on,
+                       :limit => 200).map(&:notes).uniq.each do |note|
+                         notes << Redmine::CodesetUtil.from_utf8(note, encoding)
+                       end
+        end
         csv << [ issue.id.to_s ] + col_values.collect {|c| Redmine::CodesetUtil.from_utf8(c.to_s, encoding) } +
-          (options[:description] ? [Redmine::CodesetUtil.from_utf8(issue.description, encoding)] : [])
+          (options[:issue_to] ? [related_from] : []) +
+          (options[:issue_to] ? [related_to] : []) +
+          (options[:revision] ? [revision] : []) +
+          (options[:description] ? [Redmine::CodesetUtil.from_utf8(issue.description, encoding)] : []) +
+          (options[:notes] ? notes : [])
+
       end
     end
     export

     <label><%= radio_button_tag 'columns', 'all' %> <%= l(:description_all_columns) %></label>
   </p>
   <p>
-    <label><%= check_box_tag 'description', '1' %> <%= l(:field_description) %></label>
+    <label><%= check_box_tag 'issue_to', '1' %> <%= l(:field_issue_to) %></label><br />
+<% if !@project.repository.nil? && User.current.allowed_to?(:view_changesets, @project) -%>
+    <label><%= check_box_tag 'revision', '1' %> <%= l(:label_associated_revisions) %></label>< br />
+<% end -%>
+    <label><%= check_box_tag 'description', '1' %> <%= l(:field_description) %></label>< br />
+    <label><%= check_box_tag 'notes', '1' %> <%= l(:field_notes) %></label>
   </p>
   <p class="buttons">
     <%= submit_tag l(:button_export), :name => nil, :onclick => "hideModal(this);" %>
#↑ブラウザによっては、SyntaxHighlighterがコード中の一部の-を変に認識しているから注意!