redmine_projects_sorter ver.1.0.0
githubでwatcherが増えてたので、いい加減バージョンを1以上にしよう。
しました。
yunomu/redmine_projects_sorter at v1.0.0 · GitHub
今回はDBを変更するのでmigrateが必要です。
% git clone git://github.com/yunomu/redmine_projects_sorter.git vender/plugins/redmine_projects_sorter % rake db:migrate_plugins
なにこれ
Redmineのプロジェクト一覧とかガントチャートの画面で、同じ階層にあるプロジェクトの並び順がバラバラだったので、そのへんを制御できるようにしようというプラグインです。
プロジェクト設定に"order"って要素を追加して、同じ階層のプロジェクトをorder順に並び替えます。
やったこと
DBや画面の変更を伴うRedmineプラグインの書き方。
DBの変更
migrationを書く。この辺は普通のRailsと同じ。
こんな感じ。
vendor/plugins/redmine_projects_sorter/db/migrate/001_add_order_column.rb
1 class AddOrderToProjects < ActiveRecord::Migration 2 def self.up 3 add_column :projects, :ord, :integer, 4 :null => false, :default => 100 5 end 6 7 def self.down 8 remove_column :projects, :ord 9 end 10 end
ファイル名はRails2.x仕様じゃないとRedmine的にダメらしいです。試してませんが。
で、ordってカラムを追加しました。カラム名はorderだとSQL的にややこしいし、lft,rgtに並ぶからこれでいいかって感じです。デフォはなんとなく100で。最終的にこの値の小さい順に並べます。
ロジック変更
プラグイン内で、今まではソートキーをプロジェクト名にしてたのを、ordにした。
ソースは略。
画面の変更
projectsの設定画面を変更する。そもそもまずRedmineのprojects設定画面のソースがどこにあるのかというと、
config/route.rb
184 map.with_options :controller => 'projects' do |project_mapper| 185 project_mapper.with_options :conditions => {:method => :get} do |project_views| 186 project_views.connect 'projects/:id/settings/:tab', :controller => 'projects', :action => 'settings' 187 project_views.connect 'projects/:project_id/issues/:copy_from/copy', :controller => 'issues', :action => 'new' 188 end 189 end
このあたり。projects#settingsって感じ。
app/views/projects/settings.html.erb
1 <h2><%=l(:label_settings)%></h2> 2 3 <%= render_tabs project_settings_tabs %> 4 5 <% html_title(l(:label_settings)) -%>
これだけ?
まあ、render_tabsとproject_settings_tabsを見ればいいんだろう。
render_tabsはApplicationHelperに、project_setting_tabsはProjectsHelperにあった。
app/helpers/application_helper.rb
23 module ApplicationHelper (略) 219 # Renders tabs and their content 220 def render_tabs(tabs) 221 if tabs.any? 222 render :partial => 'common/tabs', :locals => {:tabs => tabs} 223 else 224 content_tag 'p', l(:label_no_data), :class => "nodata" 225 end 226 end
app/helpers/projects_helper.rb
20 module ProjectsHelper (略) 26 def project_settings_tabs 27 tabs = [{:name => 'info', :action => :edit_project, :partial => 'projects/edit', :label => :label_information_plural}, 28 {:name => 'modules', :action => :select_project_modules, :partial => 'projects/settings/modules', :label => :label_module_plural}, 29 {:name => 'members', :action => :manage_members, :partial => 'projects/settings/members', :label => :label_member_plural}, 30 {:name => 'versions', :action => :manage_versions, :partial => 'projects/settings/versions', :label => :label_version_plural}, 31 {:name => 'categories', :action => :manage_categories, :partial => 'projects/settings/issue_categories', :label => :label_issue_category_plural} , 32 {:name => 'wiki', :action => :manage_wiki, :partial => 'projects/settings/wiki', :label => :label_wiki}, 33 {:name => 'repository', :action => :manage_repository, :partial => 'projects/settings/repository', :label => :label_repository}, 34 {:name => 'boards', :action => :manage_boards, :partial => 'projects/settings/boards', :label => :label_board_plural}, 35 {:name => 'activities', :action => :manage_project_activities, :partial => 'projects/settings/activities', :label => :enumeration_activities} 36 ] 37 tabs.select {|tab| User.current.allowed_to?(tab[:action], @project)} 38 end
Redmineの設定画面はタブがたくさんあっていろんな設定を変更できるようになっているので、たぶんこれはそのタブのディスパッチャ的なもののようです。
見たいのはinfoタブなのでinfoを見ると、":partial => 'projects/edit'"って、まあそのまんまだった。
app/views/projects/_edit.html.erb
1 <% labelled_tabular_form_for @project do |f| %> 2 <%= render :partial => 'form', :locals => { :f => f } %> 3 <%= submit_tag l(:button_save) %> 4 <% end %>
"form"を見ろ。たらい回し感。
app/views/projects/_form.html.erb
16 <p><%= f.text_field :homepage, :size => 60 %></p> 17 <p><%= f.check_box :is_public %></p> 18 <%= wikitoolbar_for 'project_description' %> 19 20 <% @project.custom_field_values.each do |value| %> 21 <p><%= custom_field_tag_with_label :project, value %></p> 22 <% end %> 23 <%= call_hook(:view_projects_form, :project => @project, :form => f) %> 24 </div>
ここらへん、17行目が公開のチェックボックスなので、このあたりに挿し込みたいんだけど……と思ってたらちょうど23行目にhookがあった。
Redmineにはpluginで拡張するためにいろんな所にhookを書けるようになっているらしい。
Hooks - Redmine
このあたりにHookの使い方が書いてある。
model、view、controller、helperのそれぞれにhookがあって、その一覧がこちら。
Hooks List - Redmine
件の"view_projects_form"なんかもありますね。今回はこれを利用します。
これの見方は、
def view_projects_form(context) # context #=> {:project => @project, :form => f} ... end
という感じのメソッドを定義してくれたらcontextにコメントみたいなハッシュを送り込みますよということらしい。パラメータの意味はviewやhelperのソースを参照するとして、全体としてはこう。
1 class ProjectsSorter < Redmine::Hook::ViewListener 2 def view_projects_form(context) 3 project = context[:project] 4 f = context[:form] 5 6 html = "<p>" 7 html << f.text_field(:ord, :value => project.ord, :type => :number) 8 html << "</p>" 9 html 10 end 11 end
ViewListenrというのを継承してやる必要があるらしい。あとはRedmineのフレームワークに則ってhtmlを返すコードを書いておく。
このListenerは、init.rbあたりでロードしておくだけでいいらしい。
vendor/plugins/redmine_projects_sorter/init.rb
(略) require "redmine_projects_sorter_listener" (略)
あとは、i18n。項目のラベルはそれなりのところに書いておかなきゃいけないそうです。
今回は"ja, form_ord"というところに書かなきゃいけないんですが、他の場合にどこに書かなきゃいけないかは、実際に動かしてみてエラーメッセージを見ながら探せばいいんじゃないでしょうか。
今回は具体的にはここ。面倒なので英語と日本語だけ。
vendor/plugins/redmine_projects_sorter/config/locales/en.yml
en:
field_ord:
Order
vendor/plugins/redmine_projects_sorter/config/locales/ja.yml
ja:
field_ord:
表示順
これで一見完成なんだけど、まだ値の更新ができない。何故か。
じゃあどこで止まってるのかと思って、だいぶ上の方で出てきたproject_settings_tabsを見てみると、":action => :edit_project"となってるけどProjectsControllerにそんなメソッドは無い。
でもどうせProjectsController#updateとかなんでしょ? と思ってparamsをログに吐くようにしてみると、まあここまでは正常に渡ってきてるっぽい。ちゃんとparams[:project][:ord]に値が入ってる。
それを保存しているようにも見える。(189行目)
app/controllers/projects_controller.rb
188 def update 189 @project.safe_attributes = params[:project] 190 if validate_parent_id && @project.save
と思ったけど、この"safe_attributes"って何だ。
app/models/projects.rb
569 safe_attributes 'name', 570 'description', 571 'homepage', 572 'is_public', 573 'identifier', 574 'custom_field_values', 575 'custom_fields', 576 'tracker_ids', 577 'issue_custom_field_ids' 578 579 safe_attributes 'enabled_module_names', 580 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
なんかチェックっぽい?
色々試してみたところ、要はこのsafe_attributesの中にordも追加してあげると、とりあえず値の更新ができるという事はわかった。
でもこれが結構難しかったので、
vendor/plugins/redmine_projects_sorter/init.rb
(略) Dispatcher.to_prepare :redmine_projects_sorter do require_dependency 'projects_helper' ProjectsHelper.send(:include, Redmine::Plugins::ProjectsSorter::ProjectsHelperPatch) Project.safe_attributes 'ord' end (略)
Dispatcherの中で書き換える。
sqlite3でしかチェックしてないからmysqlでやるとバグるかもしれませんが。
Redmineのプロジェクトの並びが気に入らない その3
まだやってました。でも大枠は完成って感じです。
- Redmineのプロジェクトの並びが気に入らないという話なのにHaskell書いてた - yunomuのブログ
- Redmineのプロジェクトの並びが気に入らないという話その2 Redmineプラグイン試作 - yunomuのブログ
ちょっと前ですが、redmine_projects_sorterをver.0.0.2にしました。0.1.0とかでもよかったかなー。
projects/indexだけじゃなくガントチャート他のプロジェクトの並び順制御にも対応しました。名前の辞書順だけですけど。
配布元:https://github.com/yunomu/redmine_projects_sorter
やったことなど
Redmineのバージョンは1.3.1です。
前回作ったプラグインは、projects/indexでのプロジェクトの並び順の制御はできていたけど、ガントチャートなどは従来のままでした。まあprojects_helperをいじっただけだからそりゃそうなんだけど。
で、やっぱりガントチャートもキレイに並び替えてくれないと使いものにならない。
ということで、ガントチャートの表示部分のデータがどこからどんな形できてるのかを追いかけてみました。
まずガントチャート画面のパスが"/projects/プロジェクト識別子/issues/gantt"なので、route.rbからそれっぽいところを探す。
config/route.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
これっぽい。Railsルールでは、これはparams[:project_id]にプロジェクト識別子を入れてgantts/showを呼んでいる。
gantts/showのviewを見る。
app/views/gantts/show.html.erb
74 <div class="gantt_subjects"> 75 <%= @gantt.subjects.html_safe %> 76 </div>
ここで表示してるっぽい。
じゃあ@gantt.subjectsはどこからきてるのか。とりあえずコントローラから辿る。
app/controllers/gantts_controller.rb
18 class GanttsController < ApplicationController (略) 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
34行目から、@ganttはそもそもRedmine::Helper::Ganttのインスタンスらしい。
このクラスはlib以下にあったので、subjectsメソッドを見てみる。
lib/redmine/helpers/gantt.rb
18 module Redmine 19 module Helpers 20 # Simple class to handle gantt chart data 21 class Gantt (略) 120 # Renders the subjects of the Gantt chart, the left side. 121 def subjects(options={}) 122 render(options.merge(:only => :subjects)) unless @subjects_rendered 123 @subjects 124 end
renderを呼んだ後に@subjectsを返してる。
@subjectsの出所を細かく追っていくと、とりあえずinitializeでは空文字が入れられているけど、実態はrenderで作ってるっぽい。
というのを追いかけてる途中で見つけた。
lib/redmine/helpers/gantt.rb
174 def render(options={}) (略) 182 Project.project_tree(projects) do |project, level| 183 options[:indent] = indent + level * options[:indent_increment] 184 render_project(project, options) 185 break if abort? 186 end
あれ、もしかしてsubjects関係なくここで書いてるんじゃない?
Project.project_treeでツリーを作って、levelでインデントを決めてrender_projectだから、たぶんこれ。読むべきはProject.project_treeだった。
app/models/project.rb
18 class Project < ActiveRecord::Base (略) 653 # Yields the given block for each project with its level in the tree 654 def self.project_tree(projects, &block) 655 ancestors = [] 656 projects.sort_by(&:lft).each do |project| 657 while (ancestors.any? && !project.is_descendant_of?(ancestors.last)) 658 ancestors.pop 659 end 660 yield project, ancestors.size 661 ancestors << project 662 end 663 end
has_many多すぎだろ……というのはさておき、
ここでlftでソート(line:656)して、レベルを添えてブロックに渡してる(line:660)。
というわけでこのメソッドを前みたいに書き換えてやればよさそう。
っていうかprojects/indexでもこれ使えよ! まあそれはいいや。HTMLのリスト系タグの入れ子構造とインデントでのツリー表現の両立は意外に大変よね。
ということでちょっとここでまたアルゴリズムを考えるターン。
lftでソートされたリストを読んで、projectとlevelの組のリストを返す関数が欲しい。んだけど、ソート済みのツリー構造を作るところまでは既に書いているのでそれを流用することにして、ツリーをレベルと要素の組のリストに変換する関数を書けばよさそう。
試しにまたHaskellで書いたのがこれ。
flatten :: [Tree] -> [(Node, Int)] flatten t = f' t 0 where f' :: [Tree] -> Int -> [(Node, Int)] f' [] _ = [] f' (T (n, cs):ts) d = (n, d):f' cs (d+1) ++ f' ts d
ついでに、これをインデント付きで表示する関数がこれ。
out :: [(Node, Int)] -> IO () out [] = return () out ((node, level):ns) = do indent level printNodeLn node out ns where indent :: Int -> IO () indent 0 = return () indent l = do putChar ' ' indent (l-1)
あ、なんかすっきり? というかどう考えても前のよりこっちの方が簡単だぞ。
Rubyで書き直すとこんな感じ。
def flatten(ts, d = 0) return [] if ts.size == 0 n, cs = ts.shift [[n, d]] + flatten(cs, d+1) + flatten(ts, d) end
割とそのまま書けた。これを使って、Project.project_treeを上書きしてやればいい。特異メソッドだからちょっとめんどかったので無理やりな感じで実装してしまいましたが。
class << Project include Redmine::Plugins::ProjectsSorter::ProjectPatch::ClassMethods def project_tree(projects, &block) flatten(sortTree(construct(projects), :name)).each do |p, l| yield p, l end end end
これで、ガントチャートのviewでもプロジェクトが名前順に並びました。
ソートのカラム指定とかソート用カラムを足すとかは、まあ当分いいや。
続き:redmine_projects_sorter ver.1.0.0 - yunomuのブログ
前の:Redmineのプロジェクトの並びが気に入らないという話その2 Redmineプラグイン試作 - 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>
まあ、そんなあたりまでは調べましたという話でした。
この辺が解明できたら、プラグインがもうちょっと実用的になるんじゃないかと思います。
今日のところはこんな感じでした。
Redmineのプロジェクトの並びが気に入らないという話なのにHaskell書いてた
Redmineを使おうと思ってセットアップしてみたんですけど、なんか、プロジェクトのソート順が気に食わないというか。
プロジェクト1, 2, 3があって、そのサブプロジェクトがそれぞれあって、
1.
1-1
1-2
1-3
2.
2-1
2-2
3.
3-1
3-2という風に、当たり前のように並んでほしいんですが、なんかそうはいかないというか。プロジェクトの登録順が影響しているのかなんなのか、プロジェクト1,2,3の並びかえができないしちゃんと並んでくれないし、サブプロジェクトも1-1,1-2,1-3なんて素直にならんでくれるわけじゃない。色々プロジェクトの設定をいじってみたけど、それで順序が変わってくれるわけじゃない。
こういうところでケチがついてRedmineやめましょうとかなるのが一番つまらない。なんとかせねば。いやよく文句言うのは私ですけど。
で、テキトウにググってみると、この点で悩んでいる人はいなくはないっぽい。けど解決してる人がいるわけでもないし、pluginとかがあるわけでもないっぽい。そんなに真面目に調べてないけど。
ということで、この順序が何に依存して決まっているのか調べてみました。Rubyだし、Railsだし、いざとなったら読めるってのは助かりますね。やっててよかったRuby on Rails。
Redmineのバージョンは1.3-stableです。
http://redmine.rubyforge.org/svn/branches/1.3-stable/
app/controllers/projects_controller.rb
47 # Lists visible projects 48 def index 49 respond_to do |format| 50 format.html { 51 @projects = Project.visible.find(:all, :order => 'lft') 52 }
このあたりをざっと見てみると、まずDBっていうかモデルから取り出す時にlftってカラムでソートしてる。
で、実際に表示している部分を見てみると、
app/views/projects/index.html.erb
13 14 <%= render_project_hierarchy(@projects)%> 15
ここで一覧を描画してるっぽい。っていうかしてる。
じゃあ次、HTMLの組立ルーチン。
app/helpers/projects_helper.rb
54 # Renders a tree of projects as a nested set of unordered lists 55 # The given collection may be a subset of the whole project tree 56 # (eg. some intermediate nodes are private and can not be seen) 57 def render_project_hierarchy(projects) 58 s = '' 59 if projects.any? 60 ancestors = [] 61 original_project = @project 62 projects.each do |project| 63 # set the project environment to please macros. 64 @project = project 65 if (ancestors.empty? || project.is_descendant_of?(ancestors.last)) 66 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n" 67 else 68 ancestors.pop 69 s << "</li>" 70 while (ancestors.any? && !project.is_descendant_of?(ancestors.last)) 71 ancestors.pop 72 s << "</ul></li>\n" 73 end 74 end 75 classes = (ancestors.empty? ? 'root' : 'child') 76 s << "<li class='#{classes}'><div class='#{classes}'>" + 77 link_to_project(project, {}, :class => "project #{User.current.member_of?(project) ? 'my-project' : nil}") 78 s << "<div class='wiki description'>#{textilizable(project.short_description, :project => project)}</div>" unless project.description.blank? 79 s << "</div>\n" 80 ancestors << project 81 end 82 s << ("</li></ul>\n" * ancestors.size) 83 @project = original_project 84 end 85 s.html_safe 86 end
ここでHTMLを組立ててる。
projectの配列を順に読んで、なにがしかのタイミングでリストの開始タグや閉じタグを書いて、それで表示上の階層構造を作っているっぽい。すごい、意図も気持ちもわかるけど私には絶対書けない、ややこしすぎて。
じゃあ最初にソートしてたlftってなんじゃいと思ってDBを覗いてみると、こんな感じになってた。index以外の項目は省略。
idx|lft|rgt 1| 14| 21 2| 8| 13 3| 2| 7 4| 15| 16 5| 17| 18 6| 9| 10 7| 11| 12 8| 3| 4 9| 5| 6 10| 1| 22 11| 19| 20
rgtがある。lft,rgtってleft,rightか!
で、調べてみると、こういうのがあるらしい。
第5回 SQLで木構造を扱う~入れ子集合モデル (1)入れ子集合モデルとは何か |gihyo.jp … 技術評論社
入れ子集合モデルは,RDBの寄って立つ集合概念を,そのまま木構造の記述にも応用できないだろうか,という発想で作られました。その根幹のアイデアは非常に簡単で,次の一文で表現できます。
木構造のノードを円(集合)とみなし,階層関係を円の包含関係として捉えなおす。
なるほど、わからん。
ざっくり言うと、RDBで階層構造を表現する方法らしい。そういや親のidとかそんなカラム無かったなっていうか、要するにカラムに親のidを持つようなポインタ的な構造はRDBは苦手なので、階層構造を改めて集合演算的に考えなおして作ったデータ構造らしい。
つまり、lftとrgtで範囲を表していて、
親lft < 子孫lft < 子孫rgt < 親rgt
ってなるようになってて、それで親子関係を表している。なるほど頭いいねぇ。
こういう構造だと、lftの値でソートしてから順に眺めていくと、HTML的にはリストタグを付けたり閉じたりするだけでうまいこと階層が表現できる。そう言われてみるとヘルパのあのコードも納得がいく。きっとテンション上がった時に一気呵成に書き上げたのでしょう。
まああれはあれでデータがでかいと確かにこうすればメモリが節約できそう……だけど、さっきのヘルパの中で表示項目を全部文字列変数(s)に蓄積してたから意味無いじゃん!
けどつまり逆に、私はそこら辺は気にせずに同レベルの項目は蓄積してソートするようにすればいいわけで。それはそれでなんか難しそうだけども。
ということで、やってみる。
とりあえずデータとしてはこんなのを用意してみました。
idx,lft,rgt 10,1,22 3,2,7 8,3,4 9,5,6 2,8,13 6,9,10 7,11,12 1,14,21 4,15,16 5,17,18 11,19,20 12,23,24
さっきのをlftの順にソートして、最後に要素を1個追加しただけです。今回はviewだけをいじりたいので、データを取り出すmodelとcontrollerの部分は変えない。ってことはviewに渡ってくるデータはこのlftでソートされたものになる。というシミュレーション。
で、これを読んでツリー構造を作るプログラムを、なぜかHaskellで書いてしまった。「そこはRubyで書けよ!」って自分でも思ってる。でもなんか意外にサクッと実装できてしまって、いい加減Haskellも慣れてきたなぁ。まあなんだかんだで半日かかりましたけど。
どっちにしろRedmineに組み込む時に書き直すんだからいいかなぁって。RubyもHaskellも大して変わんないよ(?)
コードはこれ。
exercises/rdbtree/sort.hs at master · yunomu/exercises · GitHub
今回は、CSV読み取りにcsvってライブラリを使ってみました。cabal install csvで入るやつ。
mainはいいとして、読み取ったデータの処理をする実際のメイン部分はproc関数です。
proc :: CSV -> IO () proc = printTree . sortTree . construct . csvToNodeList
CSVをNodeのリストに変換して(csvToNodeList)、それを木構造に組み立てて(construct)、木のっていうか枝の順序を並べ換えて(sortTree)、画面に表示する(printTree)。
もうちょっと詳しく。
- csvToNodeList
- 読み込んだデータが文字列(type Field)のリスト(type Record)のリスト(type CSV)として返ってくるので、それをNodeっていうかタプルのリストに変換する。tailはヘッダを除去してて、initIf (==[""])はCSVファイルの末尾に改行があった時にできる空エントリを除去してる。
csvToNodeList :: CSV -> [Node] csvToNodeList = map recordToNode . initIf (==[""]) . tail
- construct
- Nodeのリストを前から順に見ていって、木構造を作ってる。lft順に並んでいることに依存しているのでこんなのでいい。isSubsetがまさに見ての通り、cがpの子孫だったらTrueを返します。あとfilterの結果とそれに引っかからなかったものを両方返すやつが欲しくてfilter2を書いたけど、なんかそういうの無いのかな。最初はもう少し効率いいの書いてたけど読みづらかったのでこうした。
construct :: [Node] -> [Tree] construct [] = [] construct (x:xs) = (T (x, construct as)):construct bs where (as, bs) = filter2 (isSubset x) xs
isSubset
isSubset :: Node -> Node -> Bool isSubset p c = lft p < lft c && rgt c < rgt p
filter2
filter2 :: (a -> Bool) -> [a] -> ([a], [a]) filter2 _ [] = ([], []) filter2 f xs = (filter f xs, filter (not . f) xs)
- sortTree
- 最初の時点ではsortTree = idって定義してた。これについては後で。
- printTree
- Treeの画面表示って毎度めんどいよね。
結果
(10,1,22)
(3,2,7)
(8,3,4)
(9,5,6)
(2,8,13)
(6,9,10)
(7,11,12)
(1,14,21)
(4,15,16)
(5,17,18)
(11,19,20)
(12,23,24)うまくいってるっぽい。
で、これをソートすればいいわけで。さしあたりこの結果を見ただけでも子と孫でidxの並び順が合ってないから、ここらへんからいってみようか。リストの中を並べ変えればいいわけだから、なんかできそうな気がしてきた。
とかなんとかやって、githubに上がってるのが最終版です。sortTreeを、Treeのリストをidx順にソートして、それぞれのTreeの子もソートするように実装した。
コードがこれ。
sortTree :: [Tree] -> [Tree] --sortTree = id sortTree [] = [] sortTree ts = sort cmp $ map sortChild ts where cmp (T (n1, _)) (T (n2, _)) = idx n1 < idx n2 sortChild (T (n, cs)) = T (n, sortTree cs)
sortは比較関数とリストを取ってソートする関数です。簡単なので挿入ソートです。
実行結果がこれ。
(10,1,22)
(1,14,21)
(4,15,16)
(5,17,18)
(11,19,20)
(2,8,13)
(6,9,10)
(7,11,12)
(3,2,7)
(8,3,4)
(9,5,6)
(12,23,24)できてるっぽい。全体的に効率は無視してるけども。
で、これをRubyで書きなおして、pluginにでもできたらいいんだけど。めんどくさっ!!
Redmineのpluginって基本的にRailsだと思って書いていいのかな? まあそこら辺も含めてまた今度。
あ、今日はビール1000mlくらい飲んでます。お疲れ様でした。