rails/PaperTrail+ActiveScaffold の変更点
更新- 追加された行はこの色です。
- 削除された行はこの色です。
- ソフトウェア/rails/PaperTrail+ActiveScaffold へ行く。
- ソフトウェア/rails/PaperTrail+ActiveScaffold の差分を削除
[[公開メモ]] #contents * 概要 [#wf74cdc5] Rails4 でモデルの履歴情報を Paper Trail で追跡し、Active Scaffold により閲覧したい。 ** Rails の管理画面と言えば rails_admin [#h76532c9] [[rails_admin>https://github.com/sferik/rails_admin]] を使うと、Rails で動く Web アプリケーションに、データ管理画面を簡単に作成できます。 ** データの更新履歴を残したい paper_trail + paranoia [#yf81ef16] 一方で、 - [[paper_trail>https://github.com/airblade/paper_trail]]: データの更新履歴を取る - [[paranoia>https://github.com/radar/paranoia]]: 削除済みデータを閲覧・復帰可能にする を用いると、レコードの各カラムの変更履歴を残し、さらに削除済みのデータを閲覧可能になります。 ** データ更新履歴を見られる rails_admin ってないの? [#c2673708] paper_trail + paranoia に対応した rails_admin (みたいなもの)って無いんでしょうか? 見つけられなかったため、そういう管理画面を active_scaffold で作ろうともがいているのがこの記事です。 ** active_scaffold [#v10db601] [[active_scaffold>https://github.com/activescaffold/active_scaffold]] を使うことで、レコードに対する更新履歴のような has_many 子要素まで対応可能な閲覧・編集画面を簡単に作成できます。 ** ということで [#w821c2a1] paper_trail + paranoia でデータの更新履歴を残しつつ、 そのデータを active_scaffold で閲覧・編集できるようにしようともがいているのが以下の記事です。 そこそこ動くようになったところから読みたい方は、[[無理矢理ライブラリ化してみた>#l2259cc9]] からどうぞ。 なんかいろいろ車輪の再発明をしている気がするのですが、 こういう機能を簡単に持たせることって、もっと簡単にできたりしないんでしょうか??? ** 考えてみると [#z06bdbb9] active_scaffold で1から作るという方向の他に、 http://qiita.com/joker1007/items/4ac31f081c44634a5e90 などを参考にして rails_admin を paper_trail + paranoia に対応させるという方向もあって、 考えてみるとそちらの方が需要多かったのかもしれないです?いまさらですが orz 一応、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 LANG:ruby 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 とすることで、削除済みのアイテムを表示できました。 *** 2014-10-28 追記 [#t9278ff4] 上記だと親レコードからのリンクで子レコードの一覧を表示する際などに 「親レコードの子である」という制約も unscoped で外れてしまい、 全件が表示されてしまうという問題が発生することが分かりました。 LANG:ruby super.unscoped の代わりに LANG:ruby nested? ? super : super.unscoped とするのが良いようでした。 ** 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 制約に引っかかれば、ちゃんとエラーメッセージも表示されます。 * 無理矢理ライブラリ化してみた [#l2259cc9] 複数のモデルの履歴情報を管理者画面で閲覧するのに、 個々のモデルに上記の 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 関連の改善 # 2014.10.28 nested リスト表示の際の検索条件を正しくした # 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 nested? ? super() : 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 ** 想定する利用方法 [#id4456c4] 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 を使って簡単に書く [#z6de664d] モデル数が増えてくると、各モデル毎にコントローラ、 ヘルパを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 の下で動かす場合 [#h95a093f] この機能を使うのは主に管理者画面だと思うので、 - /admin/some_models - /admin/another_models のように、admin パスの下で動かす需要も多いかもしれません。 その場合には以下のようにします。 *** ファイル構成 [#vf5cc95a] ファイル構成は以下の通りです。 - 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 [#h4f60e41] 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 *** これだけ [#ya3fd174] 変更点はこれだけです。というのも、 - 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 [#u145463c] ** 削除済みレコードを淡色表示 [#ge63559b] 削除済みレコードを見やすく(見にくく)するために、 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 の動作を良く理解していないため、 正しいやり方を見つけられていません。 ** 子要素の数でソートする [#g15055d8] has_many: subitems を持つモデルについて、list の表示を subitems.count でソートしたい場合、 [[sort_by>https://github.com/activescaffold/active_scaffold/wiki/API%3A-Column#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>https://github.com/activescaffold/active_scaffold/wiki/API%3A-List#beginning_of_chain-controller-method-v23]] をいじって 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 を使えば、 テーブルにはないカラムを好きなだけ追加できるので、 いろいろ凝ったこともできそうです。 ** 削除済みレコードから親レコードへのリンクを表示しない [#y040d177] 削除済み 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 リンクをしない設定はこれで正しいんだろうか?~ [[ソースコード>https://github.com/activescaffold/active_scaffold/blob/5066df89173db70f5e96f6a89f175bee7ca9061c/lib/active_scaffold/data_structures/column.rb]] を読んでも、どうするのが正式なのかが分からない??? * ruby 2.4.0p0 + Rails5 環境では動かなくなってしまいました [#ufe1d06d] いくつか問題があるようです。 - とりあえず turbolinks5 は off にした - histories のリンクが機能していない - edit → update で失敗する ** histories のリンクが機能していない [#aafbacd1] 500 Internal Server Error が出てしまいました。 LANG:console Processing by Admin::Account::HistoriesController#index as JS Parameters: {"association"=>"histories", "parent_scaffold"=>"admin/accounts", "adapter"=>"_list_inline_adapter", "account_id"=>"1"} Account Load (0.3ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] Account Load (0.2ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] Completed 500 Internal Server Error in 6ms (ActiveRecord: 0.5ms) NoMethodError (undefined method `[]' for nil:NilClass): /home/takeuchi/.rvm/gems/ruby-2.4.0@ICSPM25/bundler/gems/active_scaffold-ab5840e57e91/lib/active_scaffold/data_structures/nested_info.rb:109:in `default_sorting' /home/takeuchi/.rvm/gems/ruby-2.4.0@ICSPM25/bundler/gems/active_scaffold-ab5840e57e91/lib/active_scaffold/data_structures/nested_info.rb:102:in `sorted?' /home/takeuchi/.rvm/gems/ruby-2.4.0@ICSPM25/bundler/gems/active_scaffold-ab5840e57e91/lib/active_scaffold/actions/nested.rb:39:in `configure_nested' activesupport (5.1.2) lib/active_support/callbacks.rb:413:in `block in make_lambda' activesupport (5.1.2) lib/active_support/callbacks.rb:197:in `block (2 levels) in halting' ... https://github.com/osamutake/active_scaffold/blob/master/lib/active_scaffold/data_structures/nested_info.rb#L109 LANG:ruby def sorted?(chain) default_sorting(chain).present? end def default_sorting(chain) return @default_sorting if defined? @default_sorting if association.scope.is_a?(Proc) && chain.respond_to?(:values) @default_sorting = chain.values[:order] @default_sorting = @default_sorting.map(&:to_sql) if @default_sorting[0].is_a? Arel::Nodes::Node @default_sorting = @default_sorting.join(', ') end end の真ん中の @default_sorting[0].is_a? Arel::Nodes::Node で、 @default_sorting が nil になって失敗しているみたい 上記の chain は https://github.com/osamutake/active_scaffold/blob/master/lib/active_scaffold/actions/nested.rb#L39 によると https://github.com/osamutake/active_scaffold/blob/master/lib/active_scaffold/actions/nested.rb#L82 の beginning_of_chain で作られているのだけれど、 必ずしも :order が設定されていない可能性がある、ということなのかも? byebug 仕込んで見てみると、 LANG:console [103, 112] in /home/takeuchi/.rvm/gems/ruby-2.4.0@ICSPM25/bundler/gems/active_scaffold-ab5840e57e91/lib/active_scaffold/data_structures/nested_info.rb 103: end 104: 105: def default_sorting(chain) 106: return @default_sorting if defined? @default_sorting 107: byebug => 108: if association.scope.is_a?(Proc) && chain.respond_to?(:values) 109: @default_sorting = chain.values[:order] 110: @default_sorting = @default_sorting.map(&:to_sql) if @default_sorting[0].is_a? Arel::Nodes::Node 111: @default_sorting = @default_sorting.join(', ') 112: end (byebug) chain CACHE Account::History Load (0.0ms) SELECT "versions".* FROM "versions" WHERE "versions"."item_id" = ? AND (item_type = "Account") LIMIT ? [["item_id", 1], ["LIMIT", 11]] #<ActiveRecord::Associations::CollectionProxy [#<Account::History id: 1, item_type: "Account", item_id: 1, event: "create", whodunnit: nil, object: nil, created_at: "2017-07-13 00:39:52", object_changes: "---\nemail:\n- \n- takeuchi@bk.tsukuba.ac.jp\npassword...", transaction_id: 1>]> なので、確かに ORDER BY 節が存在しない。 えーと・・・これ、以前のバージョンだとどうして動いてたんだ??? あれ、ruby や Rails のバージョン違いじゃなくて、 active_scaffold 自体のバージョンが上がっている問題なのかも??? 以前使っていた active_scaffold (3.4.39) だと、 https://github.com/activescaffold/active_scaffold/blob/5c5c18ccaf28ef7ab6a4f0564912bb4e0fbb8e4f/lib/active_scaffold/data_structures/nested_info.rb#L121 のようになっていて、全然違うコードだ。 # TODO: remove when rails 3 compatibility is removed というコメントが付いてある部分を何とかしようとして、バグが混入したのかもしれない? たぶん、以下のようにすれば良さそう。 LANG:ruby def default_sorting(chain) return @default_sorting if defined? @default_sorting - if association.scope.is_a?(Proc) && chain.respond_to?(:values) + if association.scope.is_a?(Proc) && chain.respond_to?(:values) && chain.values[:order] @default_sorting = chain.values[:order] @default_sorting = @default_sorting.map(&:to_sql) if @default_sorting[0].is_a? Arel::Nodes::Node @default_sorting = @default_sorting.join(', ') end end とりあえず、それらしく動くことを確認。 これでいいのかどうかよく分からないけど、一応、報告してみました。~ https://github.com/activescaffold/active_scaffold/issues/560 ** edit → update で失敗する [#g2da7dc2] まず、なぜか更新ボタンを1回押しただけで同時に2つのリクエストが飛んでいる。 LANG:console Started PATCH "/event/ICSPM25/ja/admin/invited_speakers/13?utf8=%E2%9C%93" for 127.0.0.1 at 2017-07-21 11:35:30 +0900 Started PATCH "/event/ICSPM25/ja/admin/invited_speakers/13?utf8=%E2%9C%93" for 127.0.0.1 at 2017-07-21 11:35:30 +0900 Processing by Admin::InvitedSpeakersController#update as JS Processing by Admin::InvitedSpeakersController#update as JS form の submit に jquery_ujs のイベント LANG:javascript function(e) { var form = $(this), remote = rails.isRemote(form), blankRequiredInputs, nonBlankFileInputs; if (!rails.allowAction(form)) return rails.stopEverything(e); // Skip other logic when required values are missing or file upload is present if (form.attr('novalidate') === undefined) { if (form.data('ujs:formnovalidate-button') === undefined) { blankRequiredInputs = rails.blankInputs(form, rails.requiredInputSelector, false); if (blankRequiredInputs && rails.fire(form, 'ajax:aborted:required', [blankRequiredInputs])) { return rails.stopEverything(e); } } else { // Clear the formnovalidate in case the next button click is not on a formnovalidate button // Not strictly necessary to do here, since it is also reset on each button click, but just to be certain form.data('ujs:formnovalidate-button', undefined); } } if (remote) { nonBlankFileInputs = rails.nonBlankInputs(form, rails.fileInputSelector); if (nonBlankFileInputs) { // Slight timeout so that the submit button gets properly serialized // (make it easy for event handler to serialize form without disabled values) setTimeout(function() { rails.disableFormElements(form); }, 13); var aborted = rails.fire(form, 'ajax:aborted:file', [nonBlankFileInputs]); // Re-enable form elements if event bindings return false (canceling normal form submission) if (!aborted) { setTimeout(function() { rails.enableFormElements(form); }, 13); } return aborted; } rails.handleRemote(form); return false; } else { // Slight timeout so that the submit button gets properly serialized setTimeout(function() { rails.disableFormElements(form); }, 13); } } form の 更新ボタン の click に jquery_ujs の LANG:javascript function(event) { var button = $(this); if (!rails.allowAction(button)) return rails.stopEverything(event); // Register the pressed submit button var name = button.attr('name'), data = name ? { name: name, value: button.val() } : null; var form = button.closest('form'); if (form.length === 0) { form = $('#' + button.attr('form')); } form.data('ujs:submit-button', data); // Save attributes from button form.data('ujs:formnovalidate-button', button.attr('formnovalidate')); form.data('ujs:submit-button-formaction', button.attr('formaction')); form.data('ujs:submit-button-formmethod', button.attr('formmethod')); } あれ?jquery_ujs と rails-ujs ってのが両方入ってるけど、 これっていいのか? http://qiita.com/itkrt2y/items/7e999836f460fb9c005d jquery_ujs を削ったらそもそも form が表示されなくなってしまった。 rails-ujs を削ったら正しく動いた。 えーと・・・どうなってるんだっけ? Gemfile を見直す必要がありそう。 * Rails 5 でも動くようにしたい (2017-11) [#l94df97e] ruby, rails, bundler とも現時点での最新版を使ってみます。 LANG:console $ cat ~/.gemrc install: --no-document update: --no-document $ rvm --version rvm 1.26.11 (latest) by Wayne E. Seguin <wayneeseguin@gmail.com>, Michal Papis <mpapis@gmail.com> $ echo "ruby-2.4.2" > .ruby-version $ echo "tpsf" > .ruby-gemset $ cd . ruby-2.4.2 is not installed. To install do: 'rvm install ruby-2.4.2' $ rvm install ruby-2.4.2 $ cd . $ gem --version 2.6.14 $ gem install rails $ rails --version Rails 5.1.4 $ bundler --version Bundler version 1.16.0 $ rails new tpsf ... The dependency tzinfo-data (>= 0) will be unused by any of the platforms Bundler is installing for. Bundler is installing for ruby but the dependency is only for x86-mingw32, x86-mswin32, x64-mingw32, java. To add those platforms to the bundle, run `bundle lock --add-platform x86-mingw32 x86-mswin32 x64-mingw32 java`. ... Bundle complete! 16 Gemfile dependencies, 70 gems now installed. Use `bundle info [gemname]` to see where a bundled gem is installed. run bundle exec spring binstub --all * bin/rake: spring inserted * bin/rails: spring inserted $ cd tpsf $ git add . $ git commit -m "initial commit" $ jed Gemfile - gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] + #gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] + + gem 'paper_trail' + gem 'active_scaffold', git: 'https://github.com/activescaffold/active_scaffold.git', branch: 'master', ref: 'b3a4859' + gem 'paranoia' + gem 'jquery-rails' + gem 'jquery-ui-rails' $ bundle install $ rails g active_scaffold:install Running via Spring preloader in process 13454 route concern :active_scaffold, ActiveScaffold::Routing::Basic.new(association: true) route concern :active_scaffold_association, ActiveScaffold::Routing::Association.new insert app/assets/javascripts/application.js insert app/assets/stylesheets/application.css $ rails g paper_trail:install --with-changes Running via Spring preloader in process 14299 create db/migrate/20171110151301_create_versions.rb create config/initializers/paper_trail.rb $ rails db:create Created database 'db/development.sqlite3' Created database 'db/test.sqlite3' $ rake db:migrate == 20171110151301 CreateVersions: migrating =================================== -- create_table(:versions) -> 0.0074s -- add_index(:versions, [:item_type, :item_id]) -> 0.0031s == 20171110151301 CreateVersions: migrated (0.0112s) ========================== $ git add . $ git commit -m "gem tzinfo-data removed / gems jquery, jqyery-ui, paper_trail, active_scaffold and paranoia installed" active_scaffold は新しいバージョンを入れないと動かない。 とりあえずインストール終了。 ** モデルの作成 [#t681bc7f] LANG:console $ rails g active_scaffold:resource Test name:string email:string Running via Spring preloader in process 13486 invoke active_record create db/migrate/20171110143107_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, concerns: :active_scaffold 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 == 20171110143107 CreateTests: migrating ====================================== -- create_table(:tests) -> 0.0052s == 20171110143107 CreateTests: migrated (0.0056s) ============================= $ git commit -a -m "active_scaffold を master ブランチの最新版にした" $ rails s http://localhost:3000/tests へのアクセスで一覧画面が出た。 &ref(tpsf-tests-list1.png,,75%); ** Create New でエラーが出る [#i673c95d] [Create New] を押したら、 Uncaught SyntaxError: Unexpected token < at processResponse (rails-ujs.self-817d9a8cb641f7125060cb18fefada3f35339170767c4e003105f92d4c204e39.js?body=1:246) at rails-ujs.self-817d9a8cb641f7125060cb18fefada3f35339170767c4e003105f92d4c204e39.js?body=1:173 at XMLHttpRequest.xhr.onreadystatechange (rails-ujs.self-817d9a8cb641f7125060cb18fefada3f35339170767c4e003105f92d4c204e39.js?body=1:230) エラーが出たのは、 LANG:javascript } else if (type.match(/\b(?:java|ecma)script\b/)) { script = document.createElement('script'); script.text = response; document.head.appendChild(script).parentNode.removeChild(script); appendChild の所なので、javascript を期待してたのに html が帰ってきてしまったという感じ。 えー、そんなぁ。 ** javascript の require を直す [#be38a622] LANG:console $ jed app/assets/javascripts/application.js - // require rails-ujs + //# require rails-ujs + //= require jquery + //= require jquery_ujs + //= require active_scaffold $ git commit -a -m "active_scaffold が動くように application.js の require を修正" Create New が動くようになった。 &ref(tpsf-tests-new1.png,,75%); * TrailedParanoidScaffold を仕込む [#bd13c5be] app/models/trailed_paranoid_scaffold.rb LANG:ruby # coding: utf-8 # # Osamu Takeuchi <osamu@big.jp> # # 2014.05.02 初期リリース # 2014.05.16 build を実装した # 2017.11.29 Rails 5 用に改修 & route 定義だけで使えるようにした # 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.exclude :deleted_at, :created_at, :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 conf.actions.exclude :deleted_records block.call(conf) if block 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 nested? ? super() : 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} @ #{l record.updated_at, format: :short}" + ( record.versions.last.whodunnit ? "<br> by #{record.versions.last.whodunnit}" : '')).html_safe 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.list.sorting= { :created_at => :desc } # 検索をしやすくする conf.actions.exclude :search conf.actions.add :field_search conf.field_search.columns = [:event,:created_at,:object_changes] block.call(conf) if block end end end def self.included(base) base.class_eval do extend ClassMethods protected # 親クラスは削除済みのレコードも検索する 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 define_method("#{model_id}_created_at_column") do |record, column| ( l(record.created_at)+"<br>"+ (record.whodunnit ? " by " + record.whodunnit : "") ).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(/.*\/app\/(controllers|helpers)\//,'').sub(/\.rb\z/,'') self.build_sub(file, parent, &block) end # admin/models_controller.rb # admin/models_helper.rb # admin/model/histories_controller.rb # admin/model/histories_helper.rb # # のような文字列を渡す def self.build_sub(file, parent = nil, &block) unless file =~ /([^\/]+?)(\/histories)?_(controller|helper)\z/ unless file =~ /([^\/]+)(\/histories)?_(controller|helper)\z/ throw "unexpected file name #{file}" end # 必要な情報をファイル名から取り出す model = $1.singularize # "model" or "models" controller_or_helper = $3 # "controller" or "helper" histories = $2 # "histories" or nil controller_or_helper = $3 # "controller" or "helper" model = $1 # "model" model = model.singularize unless histories *path, name = file.split(File::Separator).map{|s| s.classify} *path, name = file. split(File::Separator). map{|s| s.classify} # Admin::ModelsController # Admin::ModelsHelper # Admin::Model::HistoriesController # Admin::Model::HistoriesHelper # といった定数に結びつけたい # このとき Controller は AdminController を継承する # そのために Admin や AdminController のようなモジュールや # クラスを定義する # c = controller, n = namespace c, n = path.reduce([ApplicationController, Object]){|list, p| c, n = list if controller_or_helper == "controller" c = if Object.const_defined? (p + 'Controller'), false Object.const_get (p + 'Controller'), false else Object.const_set p + 'Controller', Class.new(c) end end n = if n.const_defined? p, false n.const_get(p, false) else n.const_set p, Module.new() end [c, n] } # ApplicationController を参照すると Helper が読み込まれるので # 必要な Controller, Helper をその前に登録する if controller_or_helper == "controller" new_class = Class.new(parent || ApplicationController) new_class = Class.new(c) 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, false)}.const_set name, new_class # n.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 ) # クラスメソッド tpsf を追加 # カラム名などに関する情報が格納される @tpsf = { model: model, model_id: ( histories ? "#{model}/history".to_sym : model.to_sym ) } def @tpsf.method_missing(name, *other) if has_key? name self[name] else super name, *other end end def self.tpsf @tpsf end # モデル名も計算しておく 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 if block end end # 必要な Model / Controller / Helper が定義されていなければ定義する def self.build_all(model) def self.build_all(path) path =~ /^\/((.*)\/)?([^\/]+)$/ model = $3 path = $1 || '' # Model::History TrailedParanoidScaffold::unless_defined(model + '/history') do |name| model.classify.constantize.class_eval do include TrailedParanoidScaffold::Model end end # Model::HistoriesHelper unless_defined(model + '/histories_helper') do |name| unless_defined(path + model + '/histories_helper') do |name| TrailedParanoidScaffold.build_sub(name) { } end # ModelsHelper TrailedParanoidScaffold::unless_defined(model.pluralize + '_helper') do |name| TrailedParanoidScaffold::unless_defined(path + model.pluralize + '_helper') do |name| TrailedParanoidScaffold.build_sub(name) { } end # ModelsController unless_defined(model.pluralize + '_controller') do |name| TrailedParanoidScaffold.build_sub(name) do |conf| TrailedParanoidScaffold.build_sub(path + name) do |conf| # 自動的には読み込まれないため手動でヘルパーを読み込む add_template_helper (model.pluralize + '_helper').classify.constantize add_template_helper (path + model.pluralize + '_helper').classify.constantize # active_scaffold を呼ぶ active_scaffold @active_scaffold_model end end # Model::HistoriesController unless_defined(model + '/histories_controller') do |name| TrailedParanoidScaffold.build_sub(name) do |conf| TrailedParanoidScaffold.build_sub(path + name) do |conf| # 自動的には読み込まれないため手動でヘルパーを読み込む add_template_helper (model + '/histories_helper').classify.constantize add_template_helper (path + model + '/histories_helper').classify.constantize # active_scaffold を呼ぶ active_scaffold @active_scaffold_model end end end # id.classify で指定された定数が見つからなければ与えられたブロックを実行する # ブロックには指定された定数が渡される def self.unless_defined(id) defined = false begin id.classify.constantize # was defined defined = true rescue NameError # was not defined end yield id unless defined 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 # 必要な Controller / Helper を定義する TrailedParanoidScaffold::build_all(@scope[:controller].singularize) TrailedParanoidScaffold::build_all(( @scope[:path] || '' ) + '/' + @scope[:controller].singularize) end end end end end paranoid のためのカラムを追加 LANG:console $ rails generate migration AddDeletedAtToTests deleted_at:datetime:index $ rake db:migrate confit/routes.rb LANG:ruby # resources :tests, concerns: :active_scaffold resources :tests, concerns: :active_scaffold do as_tpsf_routes end これだけで動くようにした。 History や Controller, Helper は定義されていなければ勝手に定義する。 ** Controller や Helper に手を加えたい場合 [#o890da96] app/controllers/tests_controller.rb LANG:ruby TrailedParanoidScaffold::build(__FILE__) do active_scaffold @active_scaffold_model do |conf| # ここに active_scaffold の column 定義など end # ここに追加の action など end app/helpers/tests_helper.rb LANG:ruby TrailedParanoidScaffold::build(__FILE__) do # ここに追加の設定を end app/controllers/test/histories_controller.rb LANG:ruby TrailedParanoidScaffold::build(__FILE__) do active_scaffold @active_scaffold_model do |conf| # ここに active_scaffold の column 定義など end # ここに追加の action など end app/helpers/test/histories_helper.rb LANG:ruby TrailedParanoidScaffold::build(__FILE__) do # ここに追加の設定を end ** 時刻の表示方法を設定 [#y5977f34] https://ja.stackoverflow.com/questions/8161/created-at%E3%81%AE%E3%82%BF%E3%82%A4%E3%83%A0%E3%82%BE%E3%83%BC%E3%83%B3%E3%81%A8%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88%E3%82%92%E5%A4%89%E6%8F%9B%E3%81%99%E3%82%8B を参考に config/application.rb LANG:ruby module Album class Application < Rails::Application ... + config.time_zone = 'Tokyo' + config.i18n.default_locale = :ja end end config/locales/ja.yml LANG:ruby + ja: + time: + formats: + default: ! '%Y/%m/%d %H:%M:%S' * 質問・コメント [#t616f744] #article_kcaptcha
Counter: 19921 (from 2010/06/03),
today: 1,
yesterday: 2