Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce support for audited_class setting per model #735

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ By default changes are stored in YAML format. If you're using PostgreSQL, then y

If you're using something other than integer primary keys (e.g. UUID) for your User model, then you can use `rails generate audited:install --audited-user-id-column-type uuid` to customize the `audits` table `user_id` column type.

If you would like to use a custom audits table, you can use `rails generate audited:install --audited-table-name custom_audits` to specify a custom audit table name, it will generate a schema migration file and a custom audit model in `app/models`.

#### Upgrading

If you're already using Audited (or acts_as_audited), your `audits` table may require additional columns. After every upgrade, please run:
Expand Down Expand Up @@ -416,7 +418,9 @@ class CustomAudit < Audited::Audit
end
end
```

Then set it in an initializer:

```ruby
# config/initializers/audited.rb

Expand All @@ -425,6 +429,31 @@ Audited.config do |config|
end
```

You can also specify a custom audit class on a per-model basis, which will override default audit class for the exact model.

```ruby
class User < ActiveRecord::Base
audited as: "CustomAudit"
end

# or with a custom class
class User < ActiveRecord::Base
audited as: CustomAudit
end
```

You can also supply a custom table name for the audit records.

```ruby
class CustomAudit < Audited::Audit
self.table_name = "custom_audits"
end

class User < ActiveRecord::Base
audited as: CustomAudit
end
```

### Enum Storage

In 4.10, the default behavior for enums changed from storing the value synthesized by Rails to the value stored in the DB. You can restore the previous behavior by setting the store_synthesized_enums configuration value:
Expand Down
55 changes: 30 additions & 25 deletions lib/audited/audit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,29 @@ 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)
@audit_class = audit_class
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["audited_changes"].type.to_s == "text"
end
end

class Audit < ::ActiveRecord::Base
Expand All @@ -46,13 +48,21 @@ class Audit < ::ActiveRecord::Base

before_create :set_version_number, :set_audit_user, :set_request_uuid, :set_remote_address

cattr_accessor :audited_class_names
self.audited_class_names = Set.new
def self.add_audited_class(audited_class)
@@audited_classes ||= {}
@@audited_classes[name] ||= Set.new
@@audited_classes[name] << audited_class
end

def self.audited_classes
@@audited_classes ||= {}
@@audited_classes[name] ||= Set.new
end

if Rails.gem_version >= Gem::Version.new("7.1")
serialize :audited_changes, coder: YAMLIfTextColumnType
serialize :audited_changes, coder: YAMLIfTextColumnType.new(self)
else
serialize :audited_changes, YAMLIfTextColumnType
serialize :audited_changes, YAMLIfTextColumnType.new(self)
end

scope :ascending, -> { reorder(version: :asc) }
Expand Down Expand Up @@ -129,11 +139,6 @@ def user_as_string
alias_method :user_as_model, :user
alias_method :user, :user_as_string

# Returns the list of classes that are being audited
def self.audited_classes
audited_class_names.map(&:constantize)
end

# All audits made during the block called will be recorded as made
# by +user+. This method is hopefully threadsafe, making it ideal
# for background operations that require audit information.
Expand Down
32 changes: 23 additions & 9 deletions lib/audited/auditor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,31 @@ def set_audit(options)

class_attribute :audit_associated_with, instance_writer: false
class_attribute :audited_options, instance_writer: false
class_attribute :audit_class, instance_writer: false

attr_accessor :audit_version, :audit_comment

set_audited_options(options)

self.audit_class = case audited_options[:as]
when String, Symbol
audited_options[:as].to_s.safe_constantize
when Class
audited_options[:as]
else
Audited.audit_class
end
if audit_class.nil?
raise "No audit class resolved. Please specify existing audit class using the `:as` option or remove it."
end

if audited_options[:comment_required]
validate :presence_of_audit_comment
before_destroy :require_comment if audited_options[:on].include?(:destroy)
end

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
has_many :audits, -> { order(version: :asc) }, as: :auditable, class_name: audit_class.name, inverse_of: :auditable
audit_class.add_audited_class(self)

after_create :audit_create if audited_options[:on].include?(:create)
before_update :audit_update if audited_options[:on].include?(:update)
Expand All @@ -102,7 +116,7 @@ def set_audit(options)
end

def has_associated_audits
has_many :associated_audits, as: :associated, class_name: Audited.audit_class.name
has_many :associated_audits, as: :associated, class_name: audit_class.name
end

def update_audited_options(new_options)
Expand Down Expand Up @@ -176,14 +190,14 @@ def revisions(from_version = 1)
# Returns nil for versions greater than revisions count
def revision(version)
if version == :previous || audits.last.version >= version
revision_with Audited.audit_class.reconstruct_attributes(audits_to(version))
revision_with audit_class.reconstruct_attributes(audits_to(version))
end
end

# Find the oldest revision recorded prior to the date/time provided.
def revision_at(date_or_time)
audits = self.audits.up_until(date_or_time)
revision_with Audited.audit_class.reconstruct_attributes(audits) unless audits.empty?
revision_with audit_class.reconstruct_attributes(audits) unless audits.empty?
end

# List of attributes that are audited.
Expand All @@ -196,8 +210,8 @@ def audited_attributes

# Returns a list combined of record audits and associated audits.
def own_and_associated_audits
Audited.audit_class.unscoped.where(auditable: self)
.or(Audited.audit_class.unscoped.where(associated: self))
audit_class.unscoped.where(auditable: self)
.or(audit_class.unscoped.where(associated: self))
.order(created_at: :desc)
end

Expand Down Expand Up @@ -229,7 +243,7 @@ def revision_with(attributes)
revision.send :instance_variable_set, "@destroyed", false
revision.send :instance_variable_set, "@_destroyed", false
revision.send :instance_variable_set, "@marked_for_destruction", false
Audited.audit_class.assign_revision_attributes(revision, attributes)
audit_class.assign_revision_attributes(revision, attributes)

# Remove any association proxies so that they will be recreated
# and reference the correct object for this revision. The only way
Expand Down Expand Up @@ -508,7 +522,7 @@ def enable_auditing
# convenience wrapper around
# @see Audit#as_user.
def audit_as(user, &block)
Audited.audit_class.as_user(user, &block)
audit_class.as_user(user, &block)
end

def auditing_enabled
Expand Down
2 changes: 1 addition & 1 deletion lib/audited/rspec_matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ def reflection
def association_exists?
!reflection.nil? &&
reflection.macro == :has_many &&
reflection.options[:class_name] == Audited.audit_class.name
reflection.options[:class_name] == model_class.audit_class.name
end
end
end
Expand Down
13 changes: 12 additions & 1 deletion lib/generators/audited/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,22 @@ 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

source_root File.expand_path("../templates", __FILE__)

def copy_migration
migration_template "install.rb", "db/migrate/install_audited.rb"
name = "db/migrate/install_audited.rb"
if options[:audited_table_name] != "audits"
name = "db/migrate/create_#{options[:audited_table_name].underscore.pluralize}.rb"
end
migration_template "install.rb", name
end

def create_custom_audit
return if options[:audited_table_name] == "audits"

template "custom_audit.rb", "app/models/#{options[:audited_table_name].singularize.underscore}.rb"
end
end
end
Expand Down
4 changes: 4 additions & 0 deletions lib/generators/audited/templates/custom_audit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<%- table_name = options[:audited_table_name].underscore.pluralize -%>
class <%= table_name.singularize.classify %> < Audited::Audit
self.table_name = "<%= table_name %>"
end
19 changes: 9 additions & 10 deletions lib/generators/audited/templates/install.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,21 +11,21 @@ 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 :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
2 changes: 2 additions & 0 deletions lib/generators/audited/upgrade_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class UpgradeGenerator < Rails::Generators::Base
include Audited::Generators::MigrationHelper
extend Audited::Generators::Migration

class_option :audited_table_name, type: :string, default: "audits", required: false

source_root File.expand_path("../templates", __FILE__)

def copy_templates
Expand Down
2 changes: 1 addition & 1 deletion spec/audited/audit_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading