rails/PaperTrail+ActiveScaffold のバックアップ差分(No.5)

更新


  • 追加された行はこの色です。
  • 削除された行はこの色です。
[[公開メモ]]

#contents

* 概要 [#wf74cdc5]

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

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

** モデルの作成 [#s2bf952d]

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) =============================

** 編集可能になった [#l51cb401]

&attachref(pure_test.png,,50%);

** Paper Trail による履歴管理を追加 [#m3ac8bd5]

このモデルに 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 の各カラムは履歴管理の必要がないため除外します。

** 編集不可能になった [#i7fcd2b8]

&attachref(trailed_test_error.png,,50%);

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

** Tests コントローラを編集 [#qb6d56f5]

app/controllers/tests_controller.rb

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

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

** 編集可能になった [#r089a68a]

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

&attachref(trailed_test_editing.png,,50%);

** モデルに履歴情報へのリンクを持たせます [#rc6f8637]

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 の設定 [#y1a0afa0]

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

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

config/routes.rb

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

** 履歴情報のコントローラ [#c82488bd]

app/controllers/test/histories_controller.rb

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

** 履歴情報へのリンクが追加されました [#u3e87868]

&attachref(link_to_histories.png,,50%);

クリックすると、

&attachref(histories_list_original.png,,50%);

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

** 履歴情報の見た目を変更 [#f8397d0b]

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

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

** 履歴部分が分かりやすくなりました [#g2eb8780]

&attachref(histories_list_modified.png,,50%);

** モデル側の調整 [#vcc49aa0]

- 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 [#qc347365]

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

&attachref(nicely_working.png,,50%);

/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 にする

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

** 削除したレコードの履歴がたどれない [#c92859b1]

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

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

* 削除されたレコードも見たい場合 [#u89cbff5]

上記の設定に加えて、歴史上存在した 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) ===================

** 歴代レコードのモデル [#bdde6f1d]

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 の設定 [#tad85ef8]

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

** とりあえず表示できるようになりました [#i48d78eb]

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

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

&attachref(historical_test.png,,50%);

** 表示を整える [#zf558dd6]

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

** 良い感じ [#faf5fe70]

&attachref(historical_test_formatted.png,,50%);

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

* 削除されたレコードも見たい2 [#ed5d780c]

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

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

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

** acts_as_paranoid の設定 [#ad305b63]

 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 を付け加え、
削除と、削除済みを考慮した重複検出が行えていることを確認できました。

** 削除済みレコードを表示する [#g0902b38]

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

app/controllers/tests_controller.rb

 LANG:ruby
 class TestsController < ApplicationController
 
   ...
   
   def beginning_of_chain
     super.unscoped
   end
 
 end

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

** histories を一覧 [#s25fec03]

上記だけだと 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

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

&attachref(acts_as_paranoid.png,,50%);

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

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

*** authorized_for で無効にする [#u527a884]

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? でフィルター [#t214ef36]

アクションを完全に消すには、
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 以外のアクションを完全に消せました。

** 削除時にレコードが消えてしまう [#u96f4983]

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

削除失敗時にはエラーメッセージを表示するため、普通に '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 アクションを追加する [#aa1a8d15]

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 上で可能

になりました。

&attachref(acts_as_paranoid2.png,,50%);

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

* 質問・コメント [#t616f744]

#article_kcaptcha


Counter: 18953 (from 2010/06/03), today: 3, yesterday: 0