diff --git a/README.md b/README.md index 7a076c2cc..d2edc1722 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,63 @@ class User < ActiveRecord::Base end ``` +### Custom Audit Attributes + +The `audit_attributes` feature allows you to dynamically set custom attributes when creating audit records. This is useful if you have added additional columns to your audit table and want to include specific values during the auditing process. + +For example, if you want to add a custom_attribute column to the audits table, create a migration: + +```ruby +class AddCustomAttributeToAudits < ActiveRecord::Migration[7.0] + def change + add_column :audits, :custom_attribute, :string + end +end +``` + +Run the migration: + +```bash +$ rails db:migrate +``` + +To use `audit_attributes`, pass a hash containing the custom attributes you want to set when creating or updating a record: + +```ruby +class User < ActiveRecord::Base + audited +end + +user = User.create!( + name: "John Doe", + audit_attributes: { custom_attribute: "Extra Info" } +) + +audit = user.audits.last +audit.custom_attribute # => "Extra Info" +``` + +The keys provided in `audit_attributes` must correspond to existing columns in your custom `audit` table. If an invalid key is included, an error will be raised: + +```ruby +user = User.create!( + name: "Steve", + audit_attributes: { invalid_key: "Invalid" } +) +# => Raises ActiveRecord::RecordInvalid +``` + +If a key in `audit_attributes` matches a predefined attribute, it will override the default value set during the auditing process: + +```ruby +user.update!( + name: "Jane Doe", + audit_attributes: { comment: "Overridden comment" } +) + +user.audits.last.comment # => "Overridden comment" +``` + ### Limiting stored audits You can limit the number of audits stored for your model. To configure limiting for all audited models, put the following in an initializer file (`config/initializers/audited.rb`): diff --git a/lib/audited/auditor.rb b/lib/audited/auditor.rb index a164f72a9..4eeb994ed 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_attributes set_audited_options(options) @@ -83,6 +83,8 @@ def set_audit(options) before_destroy :require_comment if audited_options[:on].include?(:destroy) end + validate :validate_audit_attributes + has_many :audits, -> { order(version: :asc) }, as: :auditable, class_name: Audited.audit_class.name, inverse_of: :auditable Audited.audit_class.audited_class_names << to_s @@ -348,32 +350,33 @@ def audits_to(version = nil) def audit_create write_audit(action: "create", audited_changes: audited_attributes, - comment: audit_comment) + comment: audit_comment, **safe_audit_attributes) 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, **safe_audit_attributes) 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, **safe_audit_attributes) end end def audit_destroy unless new_record? write_audit(action: "destroy", audited_changes: audited_attributes, - comment: audit_comment) + comment: audit_comment, **safe_audit_attributes) end end def write_audit(attrs) self.audit_comment = nil + self.audit_attributes = nil if auditing_enabled attrs[:associated] = send(audit_associated_with) unless audit_associated_with.nil? @@ -392,6 +395,18 @@ def presence_of_audit_comment end end + def validate_audit_attributes + return unless audit_attributes.present? + audit_columns = Audited.audit_class.column_names.map(&:to_sym) + if !audit_attributes.is_a?(Hash) || (audit_attributes.keys - audit_columns).any? + errors.add(:audit_attributes, "must be a hash including only the keys of the audit class (#{audit_columns.join(", ")})") + end + end + + def safe_audit_attributes + audit_attributes.is_a?(Hash) ? audit_attributes : {} + end + def comment_required_state? auditing_enabled && audited_changes.present? && diff --git a/spec/audited/auditor_spec.rb b/spec/audited/auditor_spec.rb index 15ff2f771..d4d113524 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_attributes: {custom_attribute: "Create Value"} } it "should change the audit count" do expect { @@ -370,6 +370,10 @@ class CallbacksSpecified < ::ActiveRecord::Base expect(user.audits.first.comment).to eq("Create") end + it "should set the audit_attributes" do + expect(user.audits.first.custom_attribute).to eq("Create Value") + 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) @@ -390,7 +394,7 @@ class CallbacksSpecified < ::ActiveRecord::Base describe "on update" do before do - @user = create_user(name: "Brandon", status: :active, audit_comment: "Update") + @user = create_user(name: "Brandon", status: :active, audit_comment: "Update", audit_attributes: {custom_attribute: "Update Value"}) end it "should save an audit" do @@ -423,6 +427,10 @@ class CallbacksSpecified < ::ActiveRecord::Base expect(@user.audits.last.comment).to eq("Update") end + it "should set the audit_attributes" do + expect(@user.audits.first.custom_attribute).to eq("Update Value") + end + it "should not save an audit if only specified on create/destroy" do on_create_destroy = Models::ActiveRecord::OnCreateDestroy.create(name: "Bart") expect { @@ -1135,7 +1143,7 @@ def stub_global_max_audits(max_audits) end describe "on update" do - let(:user) { Models::ActiveRecord::CommentRequiredUser.create!(audit_comment: "Create") } + let(:user) { Models::ActiveRecord::CommentRequiredUser.create!(audit_comment: "Create", audit_attributes: {custom_attribute: "Create Value"}) } let(:on_create_user) { Models::ActiveRecord::OnDestroyCommentRequiredUser.create } let(:on_destroy_user) { Models::ActiveRecord::OnDestroyCommentRequiredUser.create } @@ -1271,6 +1279,40 @@ def stub_global_max_audits(max_audits) end end + describe "Setting audit_attributes" do + let(:user) { create_user status: :reliable, audit_comment: "Create", audit_attributes: {custom_attribute: "Create Value", comment: "Overrided Comment"} } + + it "should change the audit count" do + expect { + user + }.to change(Models::ActiveRecord::User, :count).by(1) + end + + it "should set custom attributes" do + expect(user.audits.first.custom_attribute).to eq("Create Value") + end + + it "should override set attributes (comment)" do + expect(user.audits.first.comment).to eq("Overrided Comment") + end + + it "doesn't affect unset attributes (status)" do + expect(user.audits.first.audited_changes["status"]).to eq(1) + end + + it "validates that audit_attributes is a hash" do + expect { + Models::ActiveRecord::User.create!(audit_attributes: 4) + }.to raise_error(ActiveRecord::RecordInvalid).with_message(/Audit attributes must be a hash including only the keys of the audit class/) + end + + it "validates that audit_attributes is a hash with only valid keys" do + expect { + Models::ActiveRecord::User.create!(audit_attributes: {custom_attribute: "Create Value", comment: "Overrided Comment", invalid_key: "Invalid Key"}) + }.to raise_error(ActiveRecord::RecordInvalid).with_message(/Audit attributes must be a hash including only the keys of the audit class/) + end + end + describe "call audit multiple times" do it "should update audit options" do user = Models::ActiveRecord::UserOnlyName.create diff --git a/spec/support/active_record/postgres/3_add_column_custom_attribute_to_audits.rb b/spec/support/active_record/postgres/3_add_column_custom_attribute_to_audits.rb new file mode 100644 index 000000000..16df706dd --- /dev/null +++ b/spec/support/active_record/postgres/3_add_column_custom_attribute_to_audits.rb @@ -0,0 +1,5 @@ +class AddColumnCustomAttributeToAudits < ActiveRecord::Migration[5.0] + def change + add_column :audits, :custom_attribute, :string + end +end diff --git a/spec/support/active_record/schema.rb b/spec/support/active_record/schema.rb index 7145bc0c0..03ef2d43c 100644 --- a/spec/support/active_record/schema.rb +++ b/spec/support/active_record/schema.rb @@ -80,6 +80,7 @@ t.column :remote_address, :string t.column :request_uuid, :string t.column :created_at, :datetime + t.column :custom_attribute, :string end add_index :audits, [:auditable_id, :auditable_type], name: "auditable_index"