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