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