diff --git a/README.md b/README.md index 7a076c2c..c0608af0 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,26 @@ You can ignore the default callbacks globally unless the callback action is spec Audited.ignored_default_callbacks = [:create, :update] # ignore callbacks create and update ``` +### Context + +You can attach context to each audit using an `audit_context` attribute on your model. + +```ruby +user.update!(name: "Ryan", audit_context: {class_name: self.class.name, id: self.id}) +user.audits.last.audited_context # => {"class_name"=>"User", "id"=>1} +``` + +or using global context, it will be merged with the model context: + +```ruby +Audited.context = {class_name: self.class.name, id: self.id} +user.update!(name: "Ryan") +user.audits.last.audited_context # => {"class_name"=>"User", "id"=>1} + +user.update!(name: "Brian", audit_context: {sample_key: "sample_value"}) +user.audits.last.audited_context # => {"class_name"=>"User", "id"=>2, "sample_key"=>"sample_value"} +``` + ### Comments You can attach comments to each audit using an `audit_comment` attribute on your model. diff --git a/lib/audited.rb b/lib/audited.rb index 096959d7..0664c97e 100644 --- a/lib/audited.rb +++ b/lib/audited.rb @@ -6,6 +6,7 @@ module Audited # Wrapper around ActiveSupport::CurrentAttributes class RequestStore < ActiveSupport::CurrentAttributes attribute :audited_store + attribute :audit_context end class << self @@ -34,6 +35,16 @@ def store RequestStore.audited_store ||= {} end + def context + RequestStore.audit_context ||= {} + end + + def context=(value) + raise "context must be a hash" unless value.is_a?(Hash) + + RequestStore.audit_context = value + end + def config yield(self) end diff --git a/lib/audited/audit.rb b/lib/audited/audit.rb index e0af893e..dd1ca7f0 100644 --- a/lib/audited/audit.rb +++ b/lib/audited/audit.rb @@ -16,27 +16,30 @@ module Audited # class YAMLIfTextColumnType - class << self - def load(obj) - if text_column? - ActiveRecord::Coders::YAMLColumn.new(Object).load(obj) - else - obj - end - end + def initialize(audit_class, column_name) + @audit_class = audit_class + @column_name = column_name + end - def dump(obj) - if text_column? - ActiveRecord::Coders::YAMLColumn.new(Object).dump(obj) - else - obj - end + def load(obj) + if text_column? + ActiveRecord::Coders::YAMLColumn.new(Object).load(obj) + else + obj end + end - def text_column? - Audited.audit_class.columns_hash["audited_changes"].type.to_s == "text" + def dump(obj) + if text_column? + ActiveRecord::Coders::YAMLColumn.new(Object).dump(obj) + else + obj end end + + def text_column? + @audit_class.columns_hash[@column_name].type.to_s == "text" + end end class Audit < ::ActiveRecord::Base @@ -44,15 +47,17 @@ class Audit < ::ActiveRecord::Base belongs_to :user, polymorphic: true belongs_to :associated, polymorphic: true - before_create :set_version_number, :set_audit_user, :set_request_uuid, :set_remote_address + before_create :set_version_number, :set_audit_user, :set_request_uuid, :set_remote_address, :set_audited_context cattr_accessor :audited_class_names self.audited_class_names = Set.new if ::ActiveRecord.version >= Gem::Version.new("7.1") - serialize :audited_changes, coder: YAMLIfTextColumnType + serialize :audited_changes, coder: YAMLIfTextColumnType.new(self, "audited_changes") + serialize :audited_context, coder: YAMLIfTextColumnType.new(self, "audited_context") else - serialize :audited_changes, YAMLIfTextColumnType + serialize :audited_changes, YAMLIfTextColumnType.new(self, "audited_changes") + serialize :audited_context, YAMLIfTextColumnType.new(self, "audited_context") end scope :ascending, -> { reorder(version: :asc) } @@ -198,5 +203,9 @@ def set_request_uuid def set_remote_address self.remote_address ||= ::Audited.store[:current_remote_address] end + + def set_audited_context + self.audited_context = (::Audited.context || {}).merge(audited_context || {}) + end end end diff --git a/lib/audited/auditor.rb b/lib/audited/auditor.rb index 6f197075..1e007988 100644 --- a/lib/audited/auditor.rb +++ b/lib/audited/auditor.rb @@ -74,7 +74,7 @@ def set_audit(options) class_attribute :audit_associated_with, instance_writer: false class_attribute :audited_options, instance_writer: false - attr_accessor :audit_version, :audit_comment + attr_accessor :audit_version, :audit_comment, :audit_context set_audited_options(options) @@ -204,7 +204,8 @@ def own_and_associated_audits # Combine multiple audits into one. def combine_audits(audits_to_combine) combine_target = audits_to_combine.last - combine_target.audited_changes = audits_to_combine.pluck(:audited_changes).reduce(&:merge) + combine_target.audited_changes = audits_to_combine.select(:audited_changes).reduce({}) { |changes, audit| changes.merge!(audit.audited_changes || {}) } + combine_target.audited_context = audits_to_combine.select(:audited_context).reduce({}) { |context, audit| context.merge!(audit.audited_context || {}) } combine_target.comment = "#{combine_target.comment}\nThis audit is the result of multiple audits being combined." transaction do @@ -348,27 +349,27 @@ def audits_to(version = nil) def audit_create write_audit(action: "create", audited_changes: audited_attributes, - comment: audit_comment) + comment: audit_comment, audited_context: audit_context) end def audit_update unless (changes = audited_changes(exclude_readonly_attrs: true)).empty? && (audit_comment.blank? || audited_options[:update_with_comment_only] == false) write_audit(action: "update", audited_changes: changes, - comment: audit_comment) + comment: audit_comment, audited_context: audit_context) end end def audit_touch unless (changes = audited_changes(for_touch: true, exclude_readonly_attrs: true)).empty? write_audit(action: "update", audited_changes: changes, - comment: audit_comment) + comment: audit_comment, audited_context: audit_context) end end def audit_destroy unless new_record? write_audit(action: "destroy", audited_changes: audited_attributes, - comment: audit_comment) + comment: audit_comment, audited_context: audit_context) end end diff --git a/lib/generators/audited/install_generator.rb b/lib/generators/audited/install_generator.rb index 8bf64182..db202145 100644 --- a/lib/generators/audited/install_generator.rb +++ b/lib/generators/audited/install_generator.rb @@ -16,6 +16,8 @@ class InstallGenerator < Rails::Generators::Base class_option :audited_changes_column_type, type: :string, default: "text", required: false class_option :audited_user_id_column_type, type: :string, default: "integer", required: false + class_option :audited_table_name, type: :string, default: "audits", required: false + class_option :audited_context_column_type, type: :string, default: "text", required: false source_root File.expand_path("../templates", __FILE__) diff --git a/lib/generators/audited/templates/add_context_to_audits.rb b/lib/generators/audited/templates/add_context_to_audits.rb new file mode 100644 index 00000000..1e358721 --- /dev/null +++ b/lib/generators/audited/templates/add_context_to_audits.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +<%- table_name = options[:audited_table_name].underscore.pluralize -%> +class <%= migration_class_name %> < <%= migration_parent %> + def self.up + add_column :<%= table_name %>, :audited_context, :<%= options[:audited_context_column_type] %> + end + + def self.down + remove_column :<%= table_name %>, :audited_context + end +end diff --git a/lib/generators/audited/templates/install.rb b/lib/generators/audited/templates/install.rb index 5c6807f9..8e644535 100644 --- a/lib/generators/audited/templates/install.rb +++ b/lib/generators/audited/templates/install.rb @@ -1,8 +1,7 @@ -# frozen_string_literal: true - +<%- table_name = options[:audited_table_name].underscore.pluralize -%> class <%= migration_class_name %> < <%= migration_parent %> def self.up - create_table :audits, :force => true do |t| + create_table :<%= table_name %> do |t| t.column :auditable_id, :integer t.column :auditable_type, :string t.column :associated_id, :integer @@ -12,21 +11,22 @@ def self.up t.column :username, :string t.column :action, :string t.column :audited_changes, :<%= options[:audited_changes_column_type] %> - t.column :version, :integer, :default => 0 + t.column :version, :integer, default: 0 t.column :comment, :string t.column :remote_address, :string t.column :request_uuid, :string + t.column :audited_context, :<%= options[:audited_context_column_type] %> t.column :created_at, :datetime end - add_index :audits, [:auditable_type, :auditable_id, :version], :name => 'auditable_index' - add_index :audits, [:associated_type, :associated_id], :name => 'associated_index' - add_index :audits, [:user_id, :user_type], :name => 'user_index' - add_index :audits, :request_uuid - add_index :audits, :created_at + add_index :<%= table_name %>, [:auditable_type, :auditable_id, :version], name: "<%= table_name %>_auditable_index" + add_index :<%= table_name %>, [:associated_type, :associated_id], name: "<%= table_name %>_associated_index" + add_index :<%= table_name %>, [:user_id, :user_type], name: "<%= table_name %>_user_index" + add_index :<%= table_name %>, :request_uuid + add_index :<%= table_name %>, :created_at end def self.down - drop_table :audits + drop_table :<%= table_name %> end end diff --git a/lib/generators/audited/upgrade_generator.rb b/lib/generators/audited/upgrade_generator.rb index b66d082d..8dc9e013 100644 --- a/lib/generators/audited/upgrade_generator.rb +++ b/lib/generators/audited/upgrade_generator.rb @@ -14,11 +14,18 @@ class UpgradeGenerator < Rails::Generators::Base include Audited::Generators::MigrationHelper extend Audited::Generators::Migration + class_option :audited_table_name, type: :string, default: "audits", required: false + class_option :audited_context_column_type, type: :string, default: "text", required: false + source_root File.expand_path("../templates", __FILE__) def copy_templates - migrations_to_be_applied do |m| - migration_template "#{m}.rb", "db/migrate/#{m}.rb" + migrations_to_be_applied do |template_name| + name = "db/migrate/#{template_name}.rb" + if options[:audited_table_name] != "audits" + name = name.gsub("_to_audits", "_to_#{options[:audited_table_name]}") + end + migration_template "#{template_name}.rb", name end end @@ -64,6 +71,10 @@ def migrations_to_be_applied if indexes.any? { |i| i.columns == %w[auditable_type auditable_id] } yield :add_version_to_auditable_index end + + unless columns.include?("context") + yield :add_context_to_audits + end end end end diff --git a/spec/audited/audit_spec.rb b/spec/audited/audit_spec.rb index c18abdb5..11b18c25 100644 --- a/spec/audited/audit_spec.rb +++ b/spec/audited/audit_spec.rb @@ -70,7 +70,7 @@ class Models::ActiveRecord::CustomUserSubclass < Models::ActiveRecord::CustomUse end it "does not unserialize from binary columns" do - allow(Audited::YAMLIfTextColumnType).to receive(:text_column?).and_return(false) + allow_any_instance_of(Audited::YAMLIfTextColumnType).to receive(:text_column?).and_return(false) audit.audited_changes = {foo: "bar"} expect(audit.audited_changes).to eq "{:foo=>\"bar\"}" end diff --git a/spec/audited/auditor_spec.rb b/spec/audited/auditor_spec.rb index 15ff2f77..3dc5fbf8 100644 --- a/spec/audited/auditor_spec.rb +++ b/spec/audited/auditor_spec.rb @@ -329,7 +329,7 @@ class CallbacksSpecified < ::ActiveRecord::Base end describe "on create" do - let(:user) { create_user status: :reliable, audit_comment: "Create" } + let(:user) { create_user status: :reliable, audit_comment: "Create", audit_context: {sample_key: "sample_value"} } it "should change the audit count" do expect { @@ -370,6 +370,19 @@ class CallbacksSpecified < ::ActiveRecord::Base expect(user.audits.first.comment).to eq("Create") end + it "should store context" do + expect(user.audits.first.audited_context).to eq({sample_key: "sample_value"}) + end + + context "with global context" do + before { Audited.context[:global_key] = "global_value" } + after { Audited.context.delete(:global_key) } + + it "should merge global context" do + expect(user.audits.first.audited_context).to eq({sample_key: "sample_value", global_key: "global_value"}) + end + end + it "should not audit an attribute which is excepted if specified on create or destroy" do on_create_destroy_except_name = Models::ActiveRecord::OnCreateDestroyExceptName.create(name: "Bart") expect(on_create_destroy_except_name.audits.first.audited_changes.keys.any? { |col| ["name"].include? col }).to eq(false) diff --git a/spec/support/active_record/schema.rb b/spec/support/active_record/schema.rb index 668829ea..8e3d3c37 100644 --- a/spec/support/active_record/schema.rb +++ b/spec/support/active_record/schema.rb @@ -79,6 +79,7 @@ t.column :comment, :string t.column :remote_address, :string t.column :request_uuid, :string + t.column :audited_context, :text t.column :created_at, :datetime end diff --git a/test/upgrade_generator_test.rb b/test/upgrade_generator_test.rb index 3ec3a6b8..4d98b246 100644 --- a/test/upgrade_generator_test.rb +++ b/test/upgrade_generator_test.rb @@ -94,4 +94,24 @@ class UpgradeGeneratorTest < Rails::Generators::TestCase assert_includes(content, "class AddCommentToAudits < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]\n") end end + + test "generate migration with context column change" do + load_schema 6 + + run_generator %w[upgrade] + + assert_migration "db/migrate/add_context_to_audits.rb" do |content| + assert_match(/add_column :audits, :audited_context, :text/, content) + end + end + + test "generate migration with context column change for custom table name" do + load_schema 6 + + run_generator %w[upgrade --audited_table_name=custom_audits] + + assert_migration "db/migrate/add_context_to_custom_audits.rb" do |content| + assert_match(/add_column :custom_audits, :audited_context, :text/, content) + end + end end