rails/PaperTrail+ActiveScaffold の履歴(No.9)
更新- 履歴一覧
- 差分 を表示
- 現在との差分 を表示
- ソース を表示
- ソフトウェア/rails/PaperTrail+ActiveScaffold へ行く。
概要†
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) =============================
編集可能になった†
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 の各カラムは履歴管理の必要がないため除外します。
編集不可能になった†
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つレコードを追加した後、編集中。
モデルに履歴情報へのリンクを持たせます†
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
履歴情報へのリンクが追加されました†
クリックすると、
ちゃんと履歴情報が表示されます。
履歴情報の見た目を変更†
上記のままだと何が変更されたか見にくく、 また、履歴情報の編集ができてしまうので、いろいろ直します。
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 を編集不能にする
- 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 への参照がエラーになるなど腑に落ちない点は残りますが、 履歴情報を閲覧するという目的に限れば、これでほぼ問題なく使えると思います。
/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 されたレコードの履歴をたどる
- 存在するレコードを更新
表示を整える†
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
良い感じ†
これで削除済みのレコードを含めて、すべての履歴を参照できるようになりました。
削除されたレコードも見たい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
としたところ、ちゃんと表示されるようになりました。
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 上で可能
になりました。
リストア時に 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 のコードをファイル末尾に取り込んだ
#
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
# といった定数に結びつける
Object.const_get(path.join('::'), false).const_set name, new_class
# 新しく作ったクラス・モジュールに必要な機能を追加
new_class.class_eval do
# TrailedParanoidScaffold::Controller
# TrailedParanoidScaffold::Helper
# TrailedParanoidScaffold::HistoriesController
# TrailedParanoidScaffold::HistoriesHelper
# を include する
include Object.const_get "TrailedParanoidScaffold::" +
(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
リンクをしない設定はこれで正しいんだろうか?
ソースコード を読んでも、どうするのが正式なのかが分からない???










