yunomuのブログ

酒とゲームと上から目線

Redmineのプロジェクトの並びが気に入らないという話その2 Redmineプラグイン試作

前回の続き。
Redmineのプロジェクトの並びが気に入らないという話なのにHaskell書いてた - yunomuのブログ

前回は、プロジェクトの並び順が気に食わないのでデータ構造を調べてアルゴリズムを考えてみたという話でした。
今回はそこら辺をなんとかするRedmineのプラグインを書いてみました。

結論から言うと、projectsのトップ画面だけ、名前順にソートするってところまでできた。
yunomu/redmine_projects_sorter · GitHub
使い方。

# cd $REDMINE_BASE/vendor/plugins
# git clone https://yunomu@github.com/yunomu/redmine_projects_sorter.git

※注意:上の方のメニュー表示がちょっとおかしくなります。

なんというか、プラグインメニューで自分の名前が出てくるとちょっと楽しいですね。

Redmineプラグインの作り方

基本的にはRailsと同じです。
でもgenerateが用意されてるし、Redmineならではの部分もありそうだったので、一応それに則って作ってみました。
だいたいここに書いてあるので読むとできるんじゃないでしょうか。
プラグインの開発 | Redmine.JP

読んでられねぇ人達のために、私がやった手順。

# cd $REDMINE_BASE
# ruby script/generate redmine_plugin projects_sorter
/usr/lib/ruby/gems/1.8/gems/activerecord-2.3.14/lib/active_record/connection_adapters/abstract/connection_specification.rb:62:in `establish_connection': development database is not configured (ActiveRecord::AdapterNotSpecified)

だめだった。

どうやらプラグイン開発はdevelopment環境じゃないと動かないらしい。これ本番環境だからなぁ。
でもRailsってdevelopmentでもプラグインは自動的に再ロードしてくれないから、どっちでもいいかんじだよねぇ。ちなみにRailsのdevelopment環境では、app以下を書き換えると即座に反映してくれるという非常に便利な機能があります。

というわけで、めんどうなので無理矢理動かす。

# RAILS_ENV=production ruby script/generate redmine_plugin projects_sorter
      create  vendor/plugins/redmine_projects_sorter/app/controllers
      create  vendor/plugins/redmine_projects_sorter/app/helpers
      create  vendor/plugins/redmine_projects_sorter/app/models
      create  vendor/plugins/redmine_projects_sorter/app/views
      create  vendor/plugins/redmine_projects_sorter/db/migrate
      create  vendor/plugins/redmine_projects_sorter/lib/tasks
      create  vendor/plugins/redmine_projects_sorter/assets/images
      create  vendor/plugins/redmine_projects_sorter/assets/javascripts
      create  vendor/plugins/redmine_projects_sorter/assets/stylesheets
      create  vendor/plugins/redmine_projects_sorter/lang
      create  vendor/plugins/redmine_projects_sorter/config/locales
      create  vendor/plugins/redmine_projects_sorter/test
      create  vendor/plugins/redmine_projects_sorter/README.rdoc
      create  vendor/plugins/redmine_projects_sorter/init.rb
      create  vendor/plugins/redmine_projects_sorter/lang/en.yml
      create  vendor/plugins/redmine_projects_sorter/config/locales/en.yml
      create  vendor/plugins/redmine_projects_sorter/test/test_helper.rb

なんかいっぱいできた。見た感じ、init.rbとtest以外は標準のディレクトリ構成を作ってくれてるだけっぽい。
今回は別に大した機能を追加するわけじゃないっていうか、Helperをちょっと書き換えるだけなので、init.rbとlibと、一応README以外は消してしまいました。

そして、init.rbのテンプレに従ってプラグインの情報を書いておく(後半)。作者とか、配布元とか。
あと、moduleの読み込みなどもここに書いておく(前半)。
vender/plugins/redmine_projects_sorter/init.rb

  1 require 'redmine'
  2 require 'dispatcher'
  3 
  4 require File.dirname(__FILE__) + '/lib/redmine_projects_sorter'
  5 
  6 Dispatcher.to_prepare :redmine_projects_sorter do
  7   require_dependency 'projects_helper'
  8   ProjectsHelper.send(:include, Redmine::Plugins::ProjectsSorter)
  9 end
 10 
 11 Redmine::Plugin.register :redmine_projects_sorter do
 12   name 'Redmine Projects Sort plugin'
 13   author 'Yusuke Nomura'
 14   description 'This is a plugin for sorting projects of Redmine'
 15   version '0.0.1'
 16   url 'http://yunomu.hatenablog.jp/'
 17   author_url 'https://github.com/yunomu'
 18 end

今回はHelperのメソッドを書き換えるので、こんな感じになりました。というかこんな風にするらしいです。
moduleはかっこつけてRedmine::Plugins::ProjectsSorterとかにしてみた。(Redmine::Plugin::ProjectsSorterにしたらRedmine::Pluginっていう既存クラスと被ってエラーになった)

本体は以下のファイルに書きます。init.rbの4行目で読んでるのがこれ。
vendor/plugins/redmine_projects_sorter/lib/redmine_projects_sorter.rb

  2 module Redmine
  3   module Plugins
  4     module ProjectsSorter
  5       def self.included(base)
  6         base.send(:include, InstanceMethods)
  7         base.class_eval do
  8           alias_method_chain :render_project_hierarchy, :sort
  9         end
 10       end
 11 
 12       module InstanceMethods
        (略)
 56       end
 57     end
 58   end
 59 end

includedというのは、moduleがincludeされた時に実行されるModuleの特異メソッドで、Rubyプログラムの動きを変えたい時とかによく出てくる書き方です。
そして

  6         base.send(:include, InstanceMethods)

これで、includeしたクラスorモジュールに後のInstanceMethodsモジュールで定義しているメソッドを差し込むというか、定義する。ちなみに特異メソッドっていうかクラスメソッドを定義したい場合はClassMethodsとかいうモジュールを定義しておいて、includedの中で

base.extend ClassMethods

とかやる。

続いての

  7         base.class_eval do
  8           alias_method_chain :render_project_hierarchy, :sort
  9         end

では、差し替えたいメソッドの名前を変更している。Railsプラグインを書く時にはおなじみかもしれませんが、上のように書くと、

  • render_project_hierarchyメソッドの名前をrender_project_hierarchy_without_sortに変更
  • render_project_hierarchyメソッドを呼ぶとrender_project_hierarchy_with_sortメソッドが呼ばれる

という風に変わる。
alias_method_chain (ActiveSupport::CoreExtensions::Module) - APIdock
ということで、この後はrender_project_hierarchyの代わりに呼び出されるrender_project_hierarchy_with_sortメソッドを定義してあげればよい。さっき略した部分がこう。

 12       module InstanceMethods
 13         def render_project_hierarchy_with_sort(projects)
 14           #render_project_hierarchy_without_sort(projects)
 15           out(sortTree(construct(projects), :name))
 16         end
 17 
 18         private
 19         def subset?(p, c)
 20           p.lft < c.lft && c.rgt < p.rgt
 21         end
 22 
 23         def construct(projects)
 24           return [] if projects.size == 0
 25           ret = []
 26           p = projects.shift
 27           as = projects.select {|c| subset?(p, c) }
 28           bs = projects.select {|c| !subset?(p, c) }
 29           ret << [p, construct(as)]
 30           ret + construct(bs)
 31         end
 32 
 33         def sortTree(ts, key = :name)
 34           (ts.map {|t|
 35             [t[0], sortTree(t[1])]
 36           }).sort {|a, b|
 37             a[0].send(key) <=> b[0].send(key)
 38           }
 39         end
 40 
 41         def out(ts, depth = 0)
 42           return "" if ts.size == 0
 43           s = ""
 44           s << "<ul class='projects #{ depth == 0 ? 'root' : nil}'>\n"
 45           ts.each {|t|
 46             project = t[0]
 47             classes = (depth == 0 ? 'root' : 'child')
 48             s << "<li class='#{classes}'><div class='#{classes}'>" +
 49                   link_to_project(project, {}, :class => "project #{User.current.member_of?(project) ? 'my-project' : nil}")
 50             s << "<div class='wiki description'>#{textilizable(project.short_description, :project => project)}</div>" unless project.description.blank?
 51             s << "</div></li>\n"
 52             s << out(t[1], depth + 1)
 53           }
 54           return s + "</ul>\n"
 55         end
 56       end

render_project_hierarchy_with_sortを定義して、あとは前回Haskellで書いたアルゴリズムを実装した。出力部分(out)もまあよく見ればだいたい同じです。この中のsortTreeメソッドでソートキーをnameってハードコードしているので、ここはなんかそれなりに直さないとなぁ。最終的にはソート順みたいなカラムを追加して、プロジェクト設定フォームもいじったりなんかできるといいのかもしれないけど。まあハードコードはやめたいけども、それ以上はやる気があれば。

ということで、なんとなくプロジェクトの順序を制御することができるようになりました。

けど、これだとガントチャートの並び順が元のままだという事に気づいた。

ガントチャートの並び順について調べる

ガントチャートを見る時のURLはこうなってる。

http://hostname/projects/プロジェクト識別子/issues/gantt

この形式のパスはRailsのデフォではないので、パス書き換えルールを探ってみる。
config/routes.rb

 92   map.with_options :controller => 'gantts', :action => 'show' do |gantts_routes|
 93     gantts_routes.connect '/projects/:project_id/issues/gantt'
 94     gantts_routes.connect '/projects/:project_id/issues/gantt.:format'
 95     gantts_routes.connect '/issues/gantt.:format'
 96   end

ここ。これで、project_idをパラメータとしてGanttsController#showを呼び出しているのがわかる。
app/controllers/gantts_controller.rb

 18 class GanttsController < ApplicationController
 19   menu_item :gantt
 20   before_filter :find_optional_project
        (略)
 33   def show
 34     @gantt = Redmine::Helpers::Gantt.new(params)
 35     @gantt.project = @project
 36     retrieve_query
 37     @query.group_by = nil
 38     @gantt.query = @query if @query.valid?
 39 
 40     basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
 41 
 42     respond_to do |format|
 43       format.html { render :action => "show", :layout => !request.xhr? }
 44       format.png  { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('    to_image')
 45       format.pdf  { send_data(@gantt.to_pdf, :type => 'application/pdf', :filename => "#{basename}.pdf") }
 46     end
 47   end
 48 end

@projectってどこから来たんだよ。
といっても、あやしいのは20行目のbefore_filter :find_optional_projectしか無い。ここら辺はRailsの経験則です。
で、このクラスにはそんな定義は無かったので、親クラスのApplicationControllerかなと。
app/controllers/application_controller.rb

194   # Find a project based on params[:project_id]
195   # TODO: some subclasses override this, see about merging their logic
196   def find_optional_project
197     @project = Project.find(params[:project_id]) unless params[:project_id].blank?
198     allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
199     allowed ? true : deny_access
200   rescue ActiveRecord::RecordNotFound
201     render_404
202   end

197行目で、プロジェクト識別子をキーにしてプロジェクト情報を取り出している。
識別子は一意じゃないと登録できなくなっていたので、これで出てくるのは1件だけのはず。だけど、ガントチャートには子孫のプロジェクトやらそのチケットやらの情報が入ってくる。ってことはどっかで子孫を読んでるはずなんだけど、Redmineのプロジェクトは前回の記事で書いたみたいに子のidを持ったりする構造じゃないので、ActiveRecordの機能で裏で読み込むなんてことはできない。
ってことはfindが書き直されてるんじゃないの。
app/models/projects.rb

 18 class Project < ActiveRecord::Base
      (略)
244   def self.find(*args)
245     if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
246       project = find_by_identifier(*args)
247       raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
248       project
249     else
250       super
251     end
252   end

上の方でhas_manyとact_as_*が多すぎて泣きそうでしたけども、
予想に反して、なんかただ識別子で取り出す機能が追加されてるだけだった。find_by_*はActiveRecordで自動生成されるメソッドで、identifierで検索しますよという意味です。

といったところでお開きというか次回へつづくなわけなんですが、表示してるのはこの辺なんだけどなぁデータどっから取ってきてるんだろうなぁ。
app/views/gantts/show.html.erb

 74 <div class="gantt_subjects">
 75 <%= @gantt.subjects.html_safe %>
 76 </div>

まあ、そんなあたりまでは調べましたという話でした。
この辺が解明できたら、プラグインがもうちょっと実用的になるんじゃないかと思います。
今日のところはこんな感じでした。