yunomuのブログ

趣味のこと

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 - yunomuのブログ

Redmineのプロジェクトの並びが気に入らない その3

まだやってました。でも大枠は完成って感じです。

ちょっと前ですが、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に組み込む時に書き直すんだからいいかなぁって。RubyHaskellも大して変わんないよ(?)

コードはこれ。
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くらい飲んでます。お疲れ様でした。