rails/PaperTrail+ActiveScaffold のバックアップ(No.11)

更新


公開メモ

概要

Rails4 でモデルの履歴情報を Paper Trail で追跡し、Active Scaffold により閲覧したい。

Rails の管理画面と言えば rails_admin

rails_admin を使うと、Rails で動く Web アプリケーションに、データ管理画面を簡単に作成できます。

データの更新履歴を残したい paper_trail + paranoia

一方で、

  • paper_trail: データの更新履歴を取る
  • paranoia: 削除済みデータを閲覧・復帰可能にする

を用いると、レコードの各カラムの変更履歴を残し、さらに削除済みのデータを閲覧可能になります。

データ更新履歴を見られる rails_admin ってないの?

paper_trail + paranoia に対応した rails_admin (みたいなもの)って無いんでしょうか?

見つけられなかったため、そういう管理画面を active_scaffold で作ろうともがいているのがこの記事です。

active_scaffold

active_scaffold を使うことで、レコードに対する更新履歴のような has_many 子要素まで対応可能な閲覧・編集画面を簡単に作成できます。

ということで

paper_trail + paranoia でデータの更新履歴を残しつつ、 そのデータを active_scaffold で閲覧・編集できるようにしようともがいているのが以下の記事です。

そこそこ動くようになったところから読みたい方は、無理矢理ライブラリ化してみた からどうぞ。

なんかいろいろ車輪の再発明をしている気がするのですが、 こういう機能を簡単に持たせることって、もっと簡単にできたりしないんでしょうか???

考えてみると

active_scaffold で1から作るという方向の他に、

http://qiita.com/joker1007/items/4ac31f081c44634a5e90 などを参考にして rails_admin を paper_trail + paranoia に対応させるという方向もあって、

考えてみるとそちらの方が需要多かったのかもしれないです?いまさらですが orz

一応、active_scaffold の知識があれば管理画面のカスタマイズがいろいろ捗るというあたりが自分で作るメリットということになるのでしょうか???

削除されたレコードが見えなくなっても良い場合

モデルの作成

Active Scaffold で管理される Test というモデルを作ります。

LANG:console
$ rails g active_scaffold Test name:string email:string
       invoke  active_record
       create    db/migrate/20140424103245_create_tests.rb
       create    app/models/test.rb
       invoke    test_unit
       create      test/models/test_test.rb
       create      test/fixtures/tests.yml
        route  resources :tests do as_routes end
       invoke  active_scaffold_controller
       create    app/controllers/tests_controller.rb
       create    app/helpers/tests_helper.rb
       invoke    test_unit
       create      test/controllers/tests_controller_test.rb
       create    app/views/tests
$ rake db:migrate
 == 20140424103245 CreateTests: migrating ======================================
 -- create_table(:tests)
    -> 0.0043s
 == 20140424103245 CreateTests: migrated (0.0044s) =============================

編集可能になった

pure_test.png

Paper Trail による履歴管理を追加

このモデルに has_paper_trail を追加して、履歴情報を保存すると共に、 ActiveScaffold からその履歴情報へアクセスするための histories というプロパティを定義します。

app/models/test.rb

LANG:ruby
class Test < ActiveRecord::Base
  has_paper_trail :ignore => [:id, :created_at, :updated_at]
end

ただし、id, created_at, updated_at の各カラムは履歴管理の必要がないため除外します。

編集不可能になった

trailed_test_error.png

ActionView::Template::Error (Could not find ::PaperTrail::VersionsController or ::PaperTrail::VersionController or PaperTrail::VersionsController or PaperTrail::VersionController):

Tests コントローラを編集

app/controllers/tests_controller.rb

LANG:ruby
class TestsController < ApplicationController
  active_scaffold :"test" do |conf|
    conf.columns.exclude :versions
  end
end

ActiveScaffold が versions を表示できないので除外しました。

編集可能になった

1つレコードを追加した後、編集中。

trailed_test_editing.png

モデルに履歴情報へのリンクを持たせます

http://kingyo-bachi.blogspot.jp/2012/10/activescaffoldpapertrail.html を参考にしつつ、 多数のモデルの履歴を取るのに便利なように工夫しています。

LANG:ruby
class Test < ActiveRecord::Base
  has_paper_trail

  class History < ItemHistory
  end
  has_many :histories, -> { where 'item_type = "Test"' }, foreign_key: 'item_id', class_name: 'Test::History'
end

app/models/item_history.rb

LANG:ruby
class ItemHistory < PaperTrail::Version

  def method_missing(action)
    unless self.reify.attributes.keys.index(action.to_s).nil?
      self.reify[action]
    else
      super
    end
  end

end

PaperTrail::Version には、複数のモデルの履歴がまぜこぜに追加されるので、 自分の履歴情報のみを取り出すため、item_type="Test" を指定しています。

routes の設定

上記履歴情報へ /tests/:test_id/histories というパスでアクセスできるようにします。

コントローラ名はモデル名 Test::History に合わせなければならないため、 Test::HistoriesController とします。

config/routes.rb

LANG:ruby
resources :tests do 
  as_routes
  resources :histories, controller: 'test/histories' do
    as_routes
  end
end

履歴情報のコントローラ

app/controllers/test/histories_controller.rb

LANG:ruby
class Test::HistoriesController < ApplicationController
  active_scaffold :"test/history" do |conf|
  end
end

履歴情報へのリンクが追加されました

link_to_histories.png

クリックすると、

histories_list_original.png

ちゃんと履歴情報が表示されます。

履歴情報の見た目を変更

上記のままだと何が変更されたか見にくく、 また、履歴情報の編集ができてしまうので、いろいろ直します。

app/controllers/test/histories_controller.rb

LANG:ruby
# coding:utf-8
class Test::HistoriesController < ApplicationController
  active_scaffold :"test/history" do |conf|
    # 編集を不可にする
    conf.actions.exclude :create, :update, :delete

    # 履歴に日時を表示する
    conf.columns.add :created_at
    conf.columns[:created_at].label = "Time"

    # リストに表示する内容と順番を指定
    conf.list.columns= [:item, :event, :created_at, :object_changes]

    # 検索をしやすくする
    conf.actions.exclude :search
   conf.actions.add :field_search
    conf.field_search.columns = [:item_id,:event,:created_at]
  end
end

app/helpers/test/histories_helper.rb

LANG:ruby
module Test::HistoriesHelper

  def test_history_created_at_column(record, column)
    # record.created_at.in_time_zone(@user.time_zone) のようにしても良い
    record.created_at ? record.created_at.localtime.strftime('%m/%d %H:%M') : ''
  end
  
  def test_history_object_changes_column(record, column)
    # まるっきり美しくないけれどとりあえず
    result = "<table>"
    record.changeset.each do |k,v|
      result += "<tr><td style=\"text-align:right;border:0px;width:100px;\">"+
                "<b>#{k}</b><td style=\"border:0px;\">#{v}</tr>\n"
    end
    result += "</table>"
    result.html_safe
  end
  
  def test_history_item_column(record, column)
    "#"+record.item_id.to_s
  end
end

履歴部分が分かりやすくなりました

histories_list_modified.png

モデル側の調整

  • histories を編集不能にする
  • created_at, updated_at を表示する
  • histories の見た目を調整

app/controllers/tests_controller.rb

LANG:ruby
class TestsController < ApplicationController
  active_scaffold :"test" do |conf|
    conf.columns.exclude :versions
    
    conf.show.columns.add :created_at, :updated_at

    conf.create.columns.exclude :histories
    conf.update.columns.exclude :histories
  end
end

app/helpers/tests_helper.rb

LANG:ruby
module TestsHelper
 
  def test_updated_at_column(record, column)
    record.updated_at ? record.updated_at.localtime.strftime('%m/%d') : ''
  end

  def test_created_at_column(record, column)
    record.created_at ? record.created_at.localtime.strftime('%m/%d') : ''
  end

  def test_histories_column(record, column)
    "# " + record.versions.size.to_s
  end

end

だいたいOK

/tests/1/histories への参照がエラーになるなど腑に落ちない点は残りますが、 履歴情報を閲覧するという目的に限れば、これでほぼ問題なく使えると思います。

nicely_working.png

/tests/1/histories への参照は、

No route matches {:action=>"edit", :association=>:item, :controller=>"test/tests", :id=>"--CHILD_ID--", :parent_scaffold=>"test/histories", :test_id=>"1", :version_id=>"--ID--"}

というエラーを生じました。{:controller=>"test/tests"} がまずいのですが、 正攻法での回避方法は分かりませんでした。

この問題はクラス階層を正しく扱えないことが原因なので、

  • 履歴モデルを Test::History ではなく TestHistory にする
  • コントローラ等を Test::Histories ではなく TestHistories にする

ことにより解決されるようです。

削除したレコードの履歴がたどれない

削除したレコードの履歴もたどりたければ、上記では対応できません。

ではどうするかということで考えたのが以下になります。

削除されたレコードも見たい場合

上記の設定に加えて、歴史上存在した Test レコードをすべて一覧する、 HistoricalTest モデルを作成します。

LANG:console
$ rails g active_scaffold HistoricalTest
      invoke  active_record
      create    db/migrate/20140424121702_create_historical_tests.rb
      create    app/models/historical_test.rb
      invoke    test_unit
      create      test/models/historical_test_test.rb
      create      test/fixtures/historical_tests.yml
       route  resources :historical_tests do as_routes end
      invoke  active_scaffold_controller
      create    app/controllers/historical_tests_controller.rb
      create    app/helpers/historical_tests_helper.rb
      invoke    test_unit
      create      test/controllers/historical_tests_controller_test.rb
      create    app/views/historical_tests

データベースには historical_tests テーブルを作成する代わりに、 履歴情報を取り出すための view を同名で作成します。

db/migrate/20140424121702_create_historical_tests.rb

LANG:ruby
# coding: utf-8
class CreateHistoricalTests < ActiveRecord::Migration
  def up
    # 各レコードに対する最後の更新履歴を取り出す
    execute <<-SQL
      CREATE VIEW historical_tests AS 
        SELECT versions.id, versions.created_at as last_event_at, versions.event as last_event, 
               versions.item_id, tests.* FROM versions 
          LEFT JOIN tests ON tests.id=versions.item_id
            WHERE versions.id IN (SELECT MAX(id) FROM versions WHERE item_type = "Test" GROUP BY item_id);
    SQL
  end
  def down
    execute <<-SQL
      DROP VIEW historical_tests;
    SQL
  end
end
LANG:console
$ rake db:migrate
 == 20140424121702 CreateHistoricalTests: migrating ============================
 -- execute("      CREATE VIEW historical_tests AS \n        SELECT versions.id, 
   versions.created_at as last_event_at, versions.event as last_event, \n
   versions.item_id, tests.* FROM versions \n          LEFT JOIN tests ON 
   tests.id=versions.item_id\n            WHERE versions.id IN (SELECT MAX(id) 
   FROM versions WHERE item_type = \"Test\" GROUP BY item_id);\n")
    -> 0.0004s
 == 20140424121702 CreateHistoricalTests: migrated (0.0005s) ===================

歴代レコードのモデル

Test と Test::History への参照を持たせます。

app/models/historical_test.rb

LANG:ruby
class HistoricalTest < ActiveRecord::Base
  belongs_to :test, foreign_key: 'item_id'
  has_many :histories, -> { where 'item_type = "Test"' }, 
      primary_key: 'item_id', foreign_key: 'item_id', class_name: 'Test::History'
end

routes の設定

config/routes.rb

LANG:ruby
resources :tests do 
  as_routes
end

resources :historical_tests do 
  as_routes 
  resources :histories, controller: 'test/histories' do
    as_routes
  end
end

とりあえず表示できるようになりました

ここまでで以下が可能です。

  • destroy されたレコードの履歴をたどる
  • 存在するレコードを更新

historical_test.png

表示を整える

app/controller/historical_tests_controller.rb

LANG:ruby
class HistoricalTestsController < ApplicationController
  active_scaffold :"historical_test" do |conf|
    conf.actions.exclude :search,:create,:update,:delete,:show

    conf.columns.exclude 'id:1'

    conf.actions.add :field_search
    conf.field_search.columns.exclude :test,:histories

    conf.columns[:test].actions_for_association_links = [:show]
    conf.columns[:last_event_at].label = 'Modified'

    conf.list.columns= [:item_id, :test, :last_event_at, :histories]
  end
end

app/helper/historical_tests_helper.rb

LANG:ruby
module HistoricalTestsHelper

  def historical_test_last_event_at_column(record, column)
    record.last_event_at ? record.last_event_at.localtime.strftime('%y/%m/%d %H:%M') : ''
  end

  def historical_test_test_column(record, column)
    if record.last_event.to_s == 'destroy'
      "-- destroyed --"
    else
      "#{record.test.name} <#{record.test.email}>"
    end
  end

  def historical_test_histories_column(record, column)
      "# #{record.histories.count}"
  end

  def last_event_search_column(record, input_name)
    select :record, :last_event, options_for_select(['create','update','destroy']), 
        {:include_blank => as_(:_select_)}, input_name
  end
 
end

良い感じ

historical_test_formatted.png

これで削除済みのレコードを含めて、すべての履歴を参照できるようになりました。

削除されたレコードも見たい2

acts_as_paranoid を入れて論理削除を使えばもっと簡単になるのかも。

ということで、paranoia を入れてみました -> https://github.com/radar/paranoia

1つ問題になるのは、
https://groups.google.com/forum/#!topic/activescaffold/_ocZQgVjLl8
にあるように、ActiveScaffold で default_scope を無視した表示がしにくいことのようです。

acts_as_paranoid の設定

LANG:console
$ rails generate migration AddDeletedAtToTests deleted_at:datetime:index
$ rake db:migrate

app/models/test.rb

LANG:ruby
class Test < ActiveRecord::Base
  acts_as_paranoid
  validates :email, presence: true, uniqueness: {scope: :deleted_at}

  ...

end

acts_as_paranoid と validates uniqueness を付け加え、 削除と、削除済みを考慮した重複検出が行えていることを確認できました。

削除済みレコードを表示する

このままだと削除されたレコードが一覧に出てこないので、

app/controllers/tests_controller.rb

LANG:ruby
class TestsController < ApplicationController

  ...
  
  def beginning_of_chain
    super.unscoped
  end

end

とすることで、削除済みのアイテムを表示できました。

histories を一覧

上記だけだと histories のリンクで Request Failed となってしまいます。

LANG:console
Started GET "/tests/4/histories?association=histories&parent_scaffold=tests&adapter=_list_inline_adapter" for 192.168.133.1 at 2014-04-27 14:50:44 +0900
Processing by Test::HistoriesController#index as JS
  Parameters: {"association"=>"histories", "parent_scaffold"=>"tests", "adapter"=>"_list_inline_adapter", "test_id"=>"4"}
  Test Load (0.2ms)  SELECT "tests".* FROM "tests" WHERE "tests"."deleted_at" IS NULL AND "tests"."id" = ? LIMIT 1  [["id", "4"]]
Completed 404 Not Found in 3ms

ActiveRecord::RecordNotFound (Couldn't find Test with id=4 [WHERE "tests"."deleted_at" IS NULL]):
  activerecord (4.0.4) lib/active_record/relation/finder_methods.rb:199:in `raise_record_not_found_exception!'
  activerecord (4.0.4) lib/active_record/relation/finder_methods.rb:285:in `find_one'
  activerecord (4.0.4) lib/active_record/relation/finder_methods.rb:269:in `find_with_ids'
  activerecord (4.0.4) lib/active_record/relation/finder_methods.rb:36:in `find'
  activerecord-deprecated_finders (1.0.3) lib/active_record/deprecated_finders/relation.rb:122:in `find'
  activerecord (4.0.4) lib/active_record/querying.rb:3:in `find'
  /home/osamu/.bundler/ruby/2.0.0/active_scaffold-6b895cd14a2a/lib/active_scaffold/finder.rb:325:in `find_if_allowed'
  /home/osamu/.bundler/ruby/2.0.0/active_scaffold-6b895cd14a2a/lib/active_scaffold/actions/nested.rb:97:in `nested_parent_record'

lib/active_scaffold/actions/nested.rb:97:in `nested_parent_record' で find_if_allowed に渡されている nested.parent_model にスコープが付いているのがいけないようなので、

LANG:ruby
class Test::HistoriesController < ApplicationController

  ...

  def nested_parent_record(crud = :read)
#   @nested_parent_record ||= find_if_allowed(nested.parent_id, crud, nested.parent_model)
    @nested_parent_record ||= find_if_allowed(nested.parent_id, crud, nested.parent_model.unscoped)
  end

end

としたところ、ちゃんと表示されるようになりました。

acts_as_paranoid.png

delete 済みのレコードに edit と delete のアクションが表示される件

2つの方法があるようです。

authorized_for で無効にする

app/models/test.rb

LANG:ruby
class Test < ActiveRecord::Base
  ...
   
  def authorized_for_delete?
    not deleted_at
  end
  
   def authorized_for_update?
     not deleted_at
  end

end

とすることで、Edit や Delete の文字は残しつつ、 グレーアウトさせることができました。

skip_action_link? でフィルター

アクションを完全に消すには、 skip_action_link? でフィルターしてやればいいようです。

呼び出される際は、args[0] にレコードが渡されたり渡されなかったりするので、 渡された場合かつ deleted_at が null でない場合かつ crud_type が read 以外の場合に 表示を非許可にしました。

app/helpers/tests_helper.rb

LANG:ruby
module TestsHelper

  ...
   
  def skip_action_link?(link, *args)
    (args.size > 0) and args[0].respond_to?(:deleted_at) and args[0].deleted_at and (link.crud_type!=:read)
  end

end

これで、削除済みのレコードに対してのみ Show 以外のアクションを完全に消せました。

削除時にレコードが消えてしまう

削除したレコードも表示して欲しいので、 削除成功時には行を消去する代わりに表示し直すことにします。

削除失敗時にはエラーメッセージを表示するため、普通に 'destroy' を呼びます。

app/controllers/tests_controller.rb

class TestsController < ApplicationController
  ...

  protected

  def destroy_respond_to_js
    if successful?
      render :action => 'update_row'
    else
      # エラーメッセージを表示する
      render :action => 'destroy'
    end
  end

end

削除済みのレコードに Restore アクションを追加する

app/controllers/tests_controller.rb

LANG:ruby
class TestsController < ApplicationController
  active_scaffold :"test" do |conf|
    ...
 
    conf.action_links.add 'restore', :label => 'Restore', :type => :member, 
            :confirm => 'Are you sure you want to restore the record?', 
            :method => :put, :position => :false
  end
  ...

  def restore

  end
 
end

config/routes.rb

LANG:ruby
  resources :tests do 
    member do
      get 'restore'
    end
    as_routes
    resources :histories, controller: 'test/histories' do
      as_routes
    end
  end

app/helpers/tests_helper.rb

LANG:ruby
module TestsHelper
  ...

  def skip_action_link?(link, *args)
    return false unless (args.size > 0) and args[0].respond_to?(:deleted_at)
    
    return true if args[0].deleted_at and (link.action=='destroy' or link.action=='edit')
    return true if not args[0].deleted_at and (link.action=='restore')
  end
end

これで、削除済みのレコードにのみ Restore が表示されました。

TestsController::restore の中身は active_scaffold/lib/active_scaffold/actions/update.rb を参考にして、以下のようにすれば良いようでした。

app/controllers/tests_controller.rb

LANG:ruby
class TestsController < ApplicationController
  ...

  def restore
    do_restore
    respond_to_action(:restore)
  end
 
  protected

  def do_restore
    do_edit
    @record.deleted_at = nil
    update_save(no_record_param_update: true)
  end

  def restore_respond_to_js
    update_row
  end

  def destroy_respond_to_js
    # Update row instead of deleting
    update_row
  end

  def update_row
    if successful?
      render :action => 'update_row'
    else
      # エラーメッセージを表示する
      render :action => 'destroy'
    end
  end

end

これで、

  • acts_as_paranoid による restore 可能な削除を実現
  • 作成・編集・削除・リストアすべてを paper_trail で追跡
  • 作成・編集・削除・リストア・履歴閲覧が ActiveScaffold 上で可能

になりました。

acts_as_paranoid2.png

リストア時に uniqueness 制約に引っかかれば、ちゃんとエラーメッセージも表示されます。

無理矢理ライブラリ化してみた

複数のモデルの履歴情報を管理者画面で閲覧するのに、 個々のモデルに上記の Controller/Helper を実装するのはばからしいので、 上記を無理矢理ライブラリ化しました。

ruby の開発に慣れていないため随所に常識破りがあると思いますがご容赦ください。 こうすればいいのに、などアドバイスをいただけると大歓迎です。

config/initializers/trailed_paranoid_scaffold.rb

LANG:ruby(linenumber)
# autoload によりライブラリを読み込む
TrailedParanoidScaffold

lib/trailed_paranoid_scaffold.rb

LANG:ruby(linenumber)
# coding: utf-8
#
# Osamu Takeuchi <osamu@big.jp> 
#
# 2014.05.02 初期リリース
# 2014.05.16 build, as_tpsf_routes を実装した
#            ヘルパにユーティリティ関数を定義した
#            as_tpsf_routes のコードをファイル末尾に取り込んだ
# 2014.05.20 const_get 関連の改善
#
module TrailedParanoidScaffold

  #
  # Model に include する
  #
  module Model
    def self.included(base)
      base.class_eval do
        # 論理削除を使う
        acts_as_paranoid
        
        # 履歴情報を持たせる
        if @paper_trail_ignore
            has_paper_trail :ignore => @paper_trail_ignore
        else
            has_paper_trail :ignore => [:id, :created_at, :updated_at]
        end
        
        # http://stackoverflow.com/questions/3194290/why-cant-there-be-classes-inside-methods-in-ruby
        base.const_set :History, Class.new(PaperTrailHistory) do
        end
  
        has_many :histories, -> { where "item_type = \"#{base.to_s}\"" },
            foreign_key: 'item_id', class_name: "#{base.to_s}::History"
      end
    end
  end

  class PaperTrailHistory < PaperTrail::Version
    def method_missing(action)
      unless self.reify.attributes.keys.index(action.to_s).nil?
        self.reify[action]
      else
        super
      end
    end
  end

  #
  # ModelsController に include する
  #
  module Controller
  
    module ClassMethods
      def active_scaffold(model_id, &block)
        super(model_id) do |conf|
          conf.columns.exclude :versions
          conf.show.columns.add :created_at, :updated_at
          conf.create.columns.exclude :histories, :deleted_at
          conf.update.columns.exclude :histories, :deleted_at
          conf.list.columns.add :updated_at
          conf.action_links.add 'restore', :label => 'Restore', :type => :member, 
                  :confirm => 'Are you sure you want to restore the record?', 
                  :method => :put, :position => :false
          block.call(conf)
        end
      end
    end
  
    def self.included(base)
      base.class_eval do
        extend ClassMethods
  
        # 削除を元に戻すアクション
        def restore
          do_restore
          respond_to_action(:restore)
        end
        
        protected
        
        # 削除済みのレコードも表示する
        def beginning_of_chain
          super().unscoped
        end
  
        # acts_as_paranoid で deleted_at が立てられたレコードを復帰させる
        def do_restore
          do_edit
          @record.deleted_at = nil
          update_save(no_record_param_update: true)
        end
        
        def restore_respond_to_js
           update_row
        end
  
        def destroy_respond_to_js
          # Update row instead of deleting
          update_row
        end
  
        def update_row
          if successful?
            # 行を更新
            render :action => 'update_row'
          else
            # エラーメッセージを表示する
            render :action => 'destroy'
          end
        end
      end
    end
    
  end

  #
  # ModelsHelper に include する
  #
  module Helper

    # 削除済みのレコードに Edit および Delete のリンクを付けない
    # 削除済みのレコードだけに Restore のリンクを付ける
    def skip_action_link?(link, *args)
      return false unless (args.size > 0) and args[0].respond_to?(:deleted_at)
      return true if args[0].deleted_at and (link.action=='destroy' or link.action=='edit')
      return true if not args[0].deleted_at and (link.action=='restore')
    end
    
    def self.included(base)
      base.class_eval do
        # ヘルパークラス名( = SomeScope::SomeModelsHelper )からモデル名を取り出す
        model_id = base.to_s.split(/::/).last.gsub(/Helper$/,'').underscore.singularize

        # histories の表示を "# 数字" にする
        define_method("#{model_id}_histories_column") do |record, column|
          "# " + record.versions.size.to_s
        end
      end
    end
  
  end

  #
  # HistoriesController に include する
  #
  module HistoriesController
  
    module ClassMethods
      def active_scaffold(model_id, &block)
        super model_id do |conf|
        
          # 編集を不可にする
          conf.actions.exclude :create, :update, :delete
    
          # 履歴に日時を表示する
          conf.columns.add :created_at
          conf.columns[:created_at].label = "Time"
    
          # リストに表示する内容と順番を指定
          conf.list.columns= [:item, :event, :created_at, :object_changes]
    
          # 検索をしやすくする
          conf.actions.exclude :search
          conf.actions.add :field_search
          conf.field_search.columns = [:item_id,:event,:created_at]
          
          block.call(conf)
        end
      end
    end
  
    def self.included(base)
      base.class_eval do
        extend ClassMethods
        
        protected
        
        # 逆順で表示
        def custom_finder_options
          {reorder: "id DESC"}
        end

        # 親クラスは削除済みのレコードも検索する
        def nested_parent_record(crud = :read)
          @nested_parent_record ||= 
              find_if_allowed(nested.parent_id, crud, nested.parent_model.unscoped)
        end
      end
    end
    
  end
  
  #
  # HistoriesHelper に include する
  #
  module HistoriesHelper
  
    def self.included(base)
      base.class_eval do
        # ヘルパークラス名 (= SomeScope::SomeModel::HistoriesHelper) からモデル名を取り出す
        model_id = base.to_s.gsub(/::(?=[^:]+$)|Helper$/,'').sub(/.*::/,'').underscore.singularize
      
        # 更新履歴の表示形式
        define_method("#{model_id}_object_changes_column") do |record, column|
          # まるっきり美しくないけれどとりあえず
          result = "<table>"
          record.changeset.each do |k,v|
            result += "<tr><td style=\"text-align:right;border:0px;width:100px;\">"+
                      "<b>#{k}</b><td style=\"border:0px;\">#{v}</tr>\n"
          end
          result += "</table>"
          result.html_safe
        end
      end
    end
    
  end
  
  #
  # Controller, Helper を作成する関数
  #
  # app/controllers/admin/models_controller.rb
  # app/helpers/admin/models_helper.rb
  # app/controllers/admin/model/histories_controller.rb
  # app/helpers/admin/model/histories_helper.rb
  #
  # といったファイルから、
  #
  # TrailedParanoidScaffold::build(__FILE__) do
  # end
  #
  # の形で呼び出すことを想定している。
  #
  def self.build(file, parent = nil, &block)
    file = File.expand_path(file).sub(/\.rb\z/,'')
    unless file =~ /([^\/]+)(\/histories)?_(controller|helper)\z/
      throw "unexpected file name #{file}"
    end

    # 必要な情報をファイル名から取り出す
    model = $1                  # "model" or "models"
    histories = $2              # "histories" or nil
    controller_or_helper = $3   # "controller" or "helper"
    model = model.singularize unless histories
    
    *path, name = file.
             sub(/.*\/app\/#{controller_or_helper}s\//,'').
             split(File::Separator).
             map{|s| s.classify}

    # 無名クラス・モジュールを作成
    if controller_or_helper == "controller"
      new_class = Class.new(parent)
    else
      new_class = Module.new()
    end

    # Admin::ModelsController
    # Admin::ModelsHelper
    # Admin::Model::HistoriesController
    # Admin::Model::HistoriesHelper
    # といった定数に結びつける
    path.reduce(Object){|obj, p| obj.const_get(p)}.const_set name, new_class
    
    # 新しく作ったクラス・モジュールに必要な機能を追加
    new_class.class_eval do
      # TrailedParanoidScaffold::Controller
      # TrailedParanoidScaffold::Helper
      # TrailedParanoidScaffold::HistoriesController
      # TrailedParanoidScaffold::HistoriesHelper
      # を include する
      include TrailedParanoidScaffold.const_get(
          (histories ? "Histories" : "") + controller_or_helper.classify
      )
      
      # モデル名も計算しておく
     if controller_or_helper == "controller"
       @active_scaffold_model =
               histories ? "#{model}/history".to_sym : model.to_sym
     else
       # ヘルパにユーティリティ関数を定義する
       # 例えば def_column を使うと、
       #   def_column(:column_name) do |record, column|
       #     # def {record_name}_{column_name}_column(record, column)
       #     # として定義するのと同じ結果を得られる
       #   end
       [:column, :column_attributes, 
           :form_column, :show_column, :search_column].each do |fname|
         new_class.class_eval <<-"EOS"
           def self.def_#{fname}(name, &block)
             define_method :"#{model.underscore}_\#{name}_#{fname}", &block
           end
         EOS
       end

     end
      
      # 与えられたブロックをクラス・モジュールのスコープで評価
      new_class.class_eval &block
    end

  end

end

module ActionDispatch
  module Routing
    class Mapper
      module Base
      
        # trails_paranoid_scaffold 用の as_routes
        #
        # options には、
        #   options[:histories_resources] = {}
        #   options[:histories_routes] = {}
        # の形で、下位層へのオプションも与えられる
        #
        def as_tpsf_routes(options = {:association => true})
          options = options.dup.stringify_keys

          histories_resources = { 
            controller: "#{@scope[:controller].singularize}/histories"
          }.merge(options.delete(:histories_resources) || {})

          histories_routes = { 
            :association => true
          }.merge(options.delete(:histories_routes) || {})
          
          
          ### ここからが実際のルート指定
          
          # active_scaffold の基本
          as_routes options
          
          # 消去済みレコードの復帰用アクション
          member do 
            put 'restore' 
          end

          # histories へのルート
          resources :histories, histories_resources do
            as_routes histories_routes
          end
          
        end

      end
    end
  end
end

想定する利用方法

config/application.rb

LANG:ruby(linenumber)
    # lib 以下のモジュールを自動ロードする
    config.autoload_paths += %W(#{config.root}/lib)

app/models/some_model.rb

LANG:ruby(linenumber)
# coding:utf-8
class SomeModel < ActiveRecord::Base

  # 必要に応じて、paper_trail で無視する column を指定する
  # 指定しなければ [:id, :created_at, :updated_at] になる
  @paper_trail_ignore = [:id, :created_at, :updated_at, :some_other_column]

  # 論理削除+履歴情報 を持たせる
  include TrailedParanoidScaffold::Model 

  # もし uniqueness が必要な場合には必ず scope: :deleted_at を付ける
  validates :some_unique_column, presence: true, uniqueness: {scope: :deleted_at}
 
end

SomeModel に act_as_paranoid 用のための deleted_at カラムを追加する

LANG:console
$ rails generate migration AddDeletedAtToSomeModels deleted_at:datetime:index
$ rake db:migrate

config/routes.rb

LANG:ruby(linenumber)
  resources :some_models do
    # TrailedParanoidScaffold 用のルート指定
    as_tpsf_routes
  end

app/controllers/some_models_controller.rb

LANG:ruby(linenumber)
# coding:utf-8
class SomeModelsController < ApplicationController
  # 上記の機能を取り込む
  include TrailedParanoidScaffold::Controller

  active_scaffold :"some_model" do |conf|
    # 追加で必要な設定があればここで行う
  end
  # 必要ならここでも
end

app/helpers/some_models_helper.rb

LANG:ruby(linenumber)
# coding:utf-8
module SomeModelsHelper
  # 上記の機能を取り込む
  include TrailedParanoidScaffold::Helper

  # 日付表示の設定などがあればここで行う
  def test_updated_at_column(record, column)
    record.updated_at ? record.updated_at.localtime.strftime('%m/%d') : ''
  end

end 

app/controllers/some_model/histories_controller.rb

LANG:ruby(linenumber)
# coding:utf-8
# ソースファイル名を元に自動でコントローラを設定する
# 親にしたいコントローラを第2引数として与えることができる
# 第2引数を省略すれば ApplicationController が親になる
class SomeModel::HistoriesController < ApplicationController
  # 上記の機能を取り込む
  include TrailedParanoidScaffold::HistoriesController

  active_scaffold :"some_model/history" do |conf|
    # 追加の設定があればここで
  end
  # 必要ならここでも
end

app/helpers/some_model/histories_helper.rb

LANG:ruby(linenumber)
# coding:utf-8
module SomeModel::HistoriesHelper
  # 上記の機能を取り込む
  include TrailedParanoidScaffold::HistoriesHelper

  # 日付表示の設定など必要があれば別途
  def some_model_history_created_at_column(record, column)
    record.created_at ? record.created_at.localtime.strftime('%m/%d %H:%M') : ''
  end
  
end

TrailedParanoidScaffold.build を使って簡単に書く

モデル数が増えてくると、各モデル毎にコントローラ、 ヘルパを2個ずつ書くのが面倒になるので、 TrailedParanoidScaffold::build という関数を実装しました。

コントローラ、ヘルパを実装すべき、

  • app/controllers/some_models_controller.rb
  • app/helpers/some_models_helper.rb
  • app/controllers/some_model/histories_controller.rb
  • app/helpers/some_model/histories_helper.rb

のようなファイルからこの関数を _FILE_ を与えて呼び出せば、それぞれ

  • SomeModelsController
  • SomeModelsHelper
  • SomeModel::HistoriesController
  • SomeModel::HistoriesHelper

を適切に設定できます。これを使うことでソースファイル内にモデル名を 書かずに済むので、多数のモデルに対してコピペでコントローラ・ヘルパを量産できます。

本当はこれ専用の rails generate ができるようになればその方が良いのかもしれませんが、 現状ではそこまで手が回らなかったので、その場しのぎです。

app/controllers/some_models_controller.rb

LANG:ruby(linenumber)
# coding:utf-8
# ソースファイル名を元に自動でコントローラを設定する
# 親にしたいコントローラを第2引数として与える
# 第2引数を省略すれば ApplicationController が親になる
TrailedParanoidScaffold::build(__FILE__, AdminController) do
  active_scaffold @active_scaffold_model do |conf|
    # 追加で必要な設定があればここで行う
  end
  # 必要ならここでも
end

app/helpers/some_models_helper.rb

LANG:ruby(linenumber)
# coding:utf-8
# ソースファイル名を元に自動でヘルパーを設定する
TrailedParanoidScaffold::build(__FILE__) do
  
  # 日付表示の設定などがあればここで行う
  def test_updated_at_column(record, column)
    record.updated_at ? record.updated_at.localtime.strftime('%m/%d') : ''
  end

end 

app/controllers/some_model/histories_controller.rb

LANG:ruby(linenumber)
# coding:utf-8
# ソースファイル名を元に自動でコントローラを設定する
# 親にしたいコントローラを第2引数として与えることができる
# 第2引数を省略すれば ApplicationController が親になる
TrailedParanoidScaffold::build(__FILE__, AdminController) do
  active_scaffold @active_scaffold_model do |conf|
    # 追加の設定があればここで
  end
  # 必要ならここでも
end

app/helpers/some_model/histories_helper.rb

LANG:ruby(linenumber)
# coding:utf-8
# ソースファイル名を元に自動でヘルパーを設定する
TrailedParanoidScaffold::build(__FILE__) do

  # 日付表示の設定など必要があれば別途
  def some_model_history_created_at_column(record, column)
    record.created_at ? record.created_at.localtime.strftime('%m/%d %H:%M') : ''
  end
  
end

caller 情報を使えば _FILE_ を渡さなくて良い仕様にできるのですが、 それだとさすがに黒魔術になりすぎると思い、意図的に _FILE_ を渡すようにしています。

AdminController の下で動かす場合

この機能を使うのは主に管理者画面だと思うので、

  • /admin/some_models
  • /admin/another_models

のように、admin パスの下で動かす需要も多いかもしれません。

その場合には以下のようにします。

ファイル構成

ファイル構成は以下の通りです。

  • config/routes.rb
  • app/controllers/admin_controller.rb
  • app/controllers/admin/some_models_controller.rb
  • app/controllers/admin/some_model/histories_controller.rb
  • app/controllers/admin/another_models_controller.rb
  • app/controllers/admin/another_model/histories_controller.rb
  • app/helpers/admin/some_models_controller.rb
  • app/helpers/admin/some_model/histories_controller.rb
  • app/helpers/admin/another_models_controller.rb
  • app/helpers/admin/another_model/histories_controller.rb

ルーティング & AdminController

config/routes.rb

LANG:ruby(linenumber)
namespace 'admin' do
  resources :some_models do 
    as_tpsf_routes
  end
  resources :another_models do
    as_tpsf_routes
  end
end

app/controllers/admin_controller.rb

LANG:ruby(linenumber)
class AdminController < ApplicationController
  # 管理者のみがアクセス可能なようにする
  before_filter :assert_admin
end

これだけ

変更点はこれだけです。というのも、

  • app/controllers/admin/XXXXs_controller.rb
  • app/helpers/admin/XXXXs_controller.rb
  • app/controllers/admin/XXXX/histories_controller.rb
  • app/helpers/admin/XXXX/histories_controller.rb

の中身は上記と同じでOKなのです。

builder 関数がそれぞれファイル名から適切に判断して、

  • class Admin::XXXXsController < AdminController
  • module Admin::XXXXsHelper
  • class Admin::XXXX::HistoriesController < AdminController
  • module Admin::XXXX::HistoriesHelper

を実装してくれます。

XXXXsController, HistoriesController が AdminController を継承しているのは builder の第2引数に AdminController を指定しているためです。

これを使えば、たとえば履歴を残したいモデルが10個程度あっても 1時間もかからずに管理画面を作成できると思います。

その他の Tips

削除済みレコードを淡色表示

削除済みレコードを見やすく(見にくく)するために、 ApplicationHelper に column_attributes を仕込み、

app/controllers/application_controller.rb

LANG:ruby
module ApplicationHelper
  ...

  def column_attributes(column, record)
    if record.respond_to?(:deleted_at) and record.deleted_at
      super
      column.css_class << "record_deleted"
#      { :record_deleted => true }.merge(super)  # attribute を設定する場合
    else
      super
    end
  end
  ...

end

css で薄くできました。

app/assets/stylesheets/application.css.less

LANG:css
div.as_content table {

  .record_deleted {
    opacity: 0.3;
  }

/* attribute を設定する場合
  [record_deleted="true"] {
    opacity: 0.3;
  }
*/
}

ただこれだと、データ管理画面以外で active_scaffold を使っていると バッティングする可能性があります。

例えば AdminHelper に column_attributes を置いて、 取り込んで使えればよいと思うのですが、 include などで取り込んだ際の method_missing の動作を良く理解していないため、 正しいやり方を見つけられていません。

子要素の数でソートする

has_many: subitems を持つモデルについて、list の表示を subitems.count でソートしたい場合、 sort_by の method オプションを使えば次のように簡単にできるのだけれど、

LANG:ruby
  active_scaffold tpsf.model_id do |conf|
    conf.columns[:subitems].sort_by :method => 'subitems.count'
  end

これでは無駄なクエリが乱発されるので、大きなテーブルに対してはパフォーマンスに問題が生じそうです。

これを1回の sql 発行で済ますには、 beginning_of_chain をいじって LEFT OUTER JOIN で subitem 数を求める方法があるようです。

app/controllers/some_models_controller.rb

LANG:ruby
TrailedParanoidScaffold::build(__FILE__, AdminController) do

  def beginning_of_chain
    # subitems_count というカラムを追加しておく
   super().unscoped.joins(:presentations).select('accounts.*').
           select('COUNT(presentations.id) AS presentations_count')
  end

  active_scaffold @active_scaffold_model do |conf|
    # 追加した subitems_count でソートする
    conf.columns[:subitems].sort_by :sql => 'subitems_count'
  end

end

同様に、beginning_of_chain にて .select を使えば、 テーブルにはないカラムを好きなだけ追加できるので、 いろいろ凝ったこともできそうです。

削除済みレコードから親レコードへのリンクを表示しない

削除済み deleted_at!=NULL のレコードから belongs_to 経由で親へのリンクをたどろうとすると、 自身がすでに削除されていることから、親レコードが見付からないというエラーになってしまいます。

そこで以下のように、削除済みレコードからは親へのリンクを張らないようにしました。 削除されていなければ、表示用のリンクを張ります。

app/helpers/subitems_helper.rb

LANG:ruby
 def subitem_parent_model_column do |rec, col|
   if rec.deleted_at
     # 一時的に無効にする良い方法が分からなかったため
     # このようにしている
     col.set_link ->(dummy){ nil }
   else
     col.set_link :show
#     col.set_link :edit  # 編集リンクを張るならこちら
   end

   rec.to_s # 表示内容
 end

リンクをしない設定はこれで正しいんだろうか?
ソースコード を読んでも、どうするのが正式なのかが分からない???

質問・コメント





Counter: 18951 (from 2010/06/03), today: 4, yesterday: 0