Audit functionality for rails models - ruby-on-rails

I'm trying to implement an audit functionality for some of my rails models and store it on an external "event" database (we chose BigQuery).
Each event should be very basic:
before_json, after_json, diff, action, object_id
So, I started building this concern that I am planning on adding to my models:
module Auditable
extend ActiveSupport::Concern
included do
before_destroy {audit(:destroy)}
after_validation on: :update do
audit(:update)
end
after_validation on: :create do
audit(:create)
end
def audit(action)
EventSender.send(before_json, self.to_json, diff, action, self.id)
end
end
end
The only thing I dont know how to implement is getting the before state of the object so I can populate the relevant fields and the diff between the two states.
Any ideas on how I can do it?

I solved it the following way:
module Auditable
require 'active_record/diff'
extend ActiveSupport::Concern
included do
include ActiveRecord::Diff
before_destroy {audit(:destroy, before: before_state)}
after_validation on: :update do
audit(:update, before: before_state, after: self)
end
after_create do
audit(:create, after: self)
end
def audit(action, before: {}, after: {})
diff = case action
when :update
before.diff(after)
when :create
after
when :destroy
before
end
BigQueryClient.new.insert(
self.class.to_s.downcase,
{
before: before.to_json,
after: after.to_json,
diff: diff.to_json,
action: action,
"#{self.class.to_s.downcase.to_sym}_id": self.id
},
dataset_name: "audit"
)
end
private
def before_state
self.class.find(self.id)
end
end
end
Notice that I'm using an external gem called "activerecord-diff" to calculate the diff between the before and after.

Related

Any way to run a block right after Chewy `udpate_index`?

In a Rails app Chewy gem is used to handle ElasticSearch indexing.
I have a block of code in an after_commit and I need it to be run once a new record of the RDB is indexed in our NRDB. it looks like:
class User < ApplicationRecord
update_index('USER') { self }
after_commit :run_this_block, on: :create
def run_this_block
index = UsersIndex.find id
'do something with index'
end
end
seems the after_commit is called before update_index!!!
Nothing is found in chewy gem,
Anyone with any idea?
That could be a possible solution, though it doesn’t work in my specific case:
There is leave method in atomic strategy of Chewy which let bypass the main atomic update_index action:
class Atomic < Base
def initialize
#stash = {}
end
def update(type, objects, _options = {})
#stash[type] ||= []
#stash[type] |= type.root.id ? Array.wrap(objects) : type.adapter.identify(objects)
end
def leave
#stash.all? { |type, ids| type.import!(ids) }
end
end
It is possible to prepend leave method in chewy.rb:
# /config/initializers/chewy.rb
Chewy::Strategy::Atomic.prepend(
Module.new do
def leave
#stash.all? do |type, ids|
if INTENDED_INDICES_NAMES.include?(type.name)
type.import!(sliced_ids)
<here goes the code where one have access to recently indexed records>
else
type.import!(ids)
end
true
end
end
end

has_and_belongs_to_many relationship updates not being added to changes_to_save

Using Rails 6 and having trouble understanding how best to solve the issue I am having with a has_to_and_belongs_to_many relationship model not being added to the changes_to_save hash when adding or removing it from the parent model (used to create a timeline of changes).
Currently I have solved the issue creating a custom concern but I wondered if there is a better way of doing this?
edit- To clarify I want to track changes to the video, so when a video_tag is added or removed it will be included in video.changes_to_save
Example of what I have (base model)
class video < Base::StandardModel
...
has_and_belongs_to_many :video_tags,
before_add: :make_dirty,
before_remove: :make_dirty
...
Dirty Association Concern
module DirtyAssociationsConcern
extend ActiveSupport::Concern
included do
attr_accessor :dirty_association_name
attr_accessor :dirty_association_before_changes
def make_dirty(record)
if self.dirty_association_before_changes.nil?
self.dirty_association_before_changes = self.send(association_name(record)).map(&:name).join(", ")
self.dirty_association_name = association_name(record)
end
end
def has_changes_to_save?
dirty_association_name.present? || super
end
def saved_changes?
dirty_association_name.present? || super
end
def changes_to_save
dirty_association_name.present? ? build_changes.merge(super) : super
end
def saved_changes
dirty_association_name.present? ? build_changes.merge(super) : super
end
private
def association_name(record)
record.class.to_s.underscore.pluralize
end
def association_name_ids
"#{dirty_association_name.singularize}_ids"
end
def build_changes
{ association_name_ids => [dirty_association_before_changes, self.send(dirty_association_name).map(&:name).join(", ")] }
end
end
end

How to simplify the soft delete process with Ruby on Rails?

I want to have a model where I need to soft delete a record and not show them in the find or any other conditions while searching.
I want to retain the model without deleting the record. How to go about this?
Just use a concern in rails 4
Example here
module SoftDeletable
extend ActiveSupport::Concern
included do
default_scope { where(is_deleted: false) }
scope :only_deleted, -> { unscope(where: :is_deleted).where(is_deleted: true) }
end
def delete
update_column :is_deleted, true if has_attribute? :is_deleted
end
def destroy;
callbacks_result = transaction do
run_callbacks(:destroy) do
delete
end
end
callbacks_result ? self : false
end
def self.included(klazz)
klazz.extend Callbacks
end
module Callbacks
def self.extended(klazz)
klazz.define_callbacks :restore
klazz.define_singleton_method("before_restore") do |*args, &block|
set_callback(:restore, :before, *args, &block)
end
klazz.define_singleton_method("around_restore") do |*args, &block|
set_callback(:restore, :around, *args, &block)
end
klazz.define_singleton_method("after_restore") do |*args, &block|
set_callback(:restore, :after, *args, &block)
end
end
end
def restore!(opts = {})
self.class.transaction do
run_callbacks(:restore) do
update_column :is_deleted, false
restore_associated_records if opts[:recursive]
end
end
self
end
alias :restore :restore!
def restore_associated_records
destroyed_associations = self.class.reflect_on_all_associations.select do |association|
association.options[:dependent] == :destroy
end
destroyed_associations.each do |association|
association_data = send(association.name)
unless association_data.nil?
if association_data.is_deleted?
if association.collection?
association_data.only_deleted.each { |record| record.restore(recursive: true) }
else
association_data.restore(recursive: true)
end
end
end
if association_data.nil? && association.macro.to_s == 'has_one'
association_class_name = association.options[:class_name].present? ? association.options[:class_name] : association.name.to_s.camelize
association_foreign_key = association.options[:foreign_key].present? ? association.options[:foreign_key] : "#{self.class.name.to_s.underscore}_id"
Object.const_get(association_class_name).only_deleted.where(association_foreign_key, self.id).first.try(:restore, recursive: true)
end
end
clear_association_cache if destroyed_associations.present?
end
end
Deletable
A rails concern to add soft deletes.
Very simple and flexible way to customise/ change
(You can change the delete column to be a timestamp and change the methods to call ActiveRecord touch ).
Best where you want to control code not have gems for simple tasks.
Usage
In your Tables add a boolean column is_deletable
class AddDeletedAtToUsers < ActiveRecord::Migration
def change
add_column :users, :is_deleted, :boolean
end
end
In your models
class User < ActiveRecord::Base
has_many :user_details, dependent: :destroy
include SoftDeletable
end
Methods and callbacks available:
User.only_deleted
User.first.destroy
User.first.restore
User.first.restore(recursive: true)
Note:
Focus Using update_column or touch if you decide to use a timestamp column.
Edited
If you are using rails <= 3.x (this example also use a DateTime field instead boolean), there are some differences:
module SoftDeletable
extend ActiveSupport::Concern
included do
default_scope { where(deleted_at: nil }
# In Rails <= 3.x to use only_deleted, do something like 'data = Model.unscoped.only_deleted'
scope :only_deleted, -> { unscoped.where(table_name+'.deleted_at IS NOT NULL') }
end
def delete
update_column :deleted_at, DateTime.now if has_attribute? :deleted_at
end
# ... ... ...
# ... OTHERS IMPLEMENTATIONS ...
# ... ... ...
def restore!(opts = {})
self.class.transaction do
run_callbacks(:restore) do
# Remove default_scope. "UPDATE ... WHERE (deleted_at IS NULL)"
self.class.send(:unscoped) do
update_column :deleted_at, nil
restore_associated_records if opts[:recursive]
end
end
end
self
end
alias :restore :restore!
def restore_associated_records
destroyed_associations = self.class.reflect_on_all_associations.select do |association|
association.options[:dependent] == :destroy
end
destroyed_associations.each do |association|
association_data = send(association.name)
unless association_data.nil?
if association_data.deleted_at?
if association.collection?
association_data.only_deleted.each { |record| record.restore(recursive: true) }
else
association_data.restore(recursive: true)
end
end
end
if association_data.nil? && association.macro.to_s == 'has_one'
association_class_name = association.options[:class_name].present? ? association.options[:class_name] : association.name.to_s.camelize
association_foreign_key = association.options[:foreign_key].present? ? association.options[:foreign_key] : "#{self.class.name.to_s.underscore}_id"
Object.const_get(association_class_name).only_deleted.where(association_foreign_key, self.id).first.try(:restore, recursive: true)
end
end
clear_association_cache if destroyed_associations.present?
end
end
Usage
In your Tables add a DateTime column deleted_at
class AddDeletedAtToUsers < ActiveRecord::Migration
def change
add_column :users, :deleted_at, :datetime
end
end
Try this gem : https://github.com/technoweenie/acts_as_paranoid - ActiveRecord plugin allowing you to hide and restore records without actually deleting them
Just add a boolean field called deleted or something to that effect. When you soft delete the record just set that field to true.
When doing a find just add that as a condition (or make a scope for it).
The default_scope functionality in ActiveRecord 3 makes this easy, but personally, I favor the wide variety of standard solutions that can be dropped into the project. acts_as_archive in particular is the best fit for most of my projects, since it moves infrequently-accessed deleted records to a separate table, allowing the base table to stay small and in the database server's RAM.
Depending on your needs, you may also want to consider versioning instead of soft deletion.
Add a date field to your model - deleted_at.
Override the delete (or destroy) method on your model to set the deleted_at value. You can also create it as a new method. Something like soft_delete.
Add a restore/undelete method to your model to set the deleted_at value back to null.
Optional: create an alias method for the original delete (or destroy) method. Name it something like hard_delete.
You can define a module like this
module ActiveRecordScope
def self.included(base)
base.scope :not_deleted, -> { base.where(deleted: false) }
base.send(:default_scope) { base.not_deleted }
base.scope :only_deleted, -> { base.unscope(where: :deleted).where(deleted: true) }
def delete
update deleted: true
end
def recover
update deleted: false
end
end
end
Then in your class, you can write something like:
class User < ActiveRecord::Base
include ActiveRecordScope
end
So you have both soft delete and recover.
You call user.delete to soft delete an user. Then you can call user.recover to set the deleted back to false again, and recover it.
Have a look at rails3_acts_as_paranoid.
A simple plugin which hides records instead of deleting them, being
able to recover them.
...
This plugin was inspired by acts_as_paranoid and acts_as_active.
Usage:
class Paranoiac < ActiveRecord::Base
acts_as_paranoid
scope :pretty, where(:pretty => true)
end
Paranoiac.create(:pretty => true)
Paranoiac.pretty.count #=> 1
Paranoiac.only_deleted.count #=> 0
Paranoiac.pretty.only_deleted.count #=> 0
Paranoiac.first.destroy
Paranoiac.pretty.count #=> 0
Paranoiac.only_deleted.count #=> 1
Paranoiac.pretty.only_deleted.count #=> 1
If you use Rails4, Try this gem : https://github.com/alfa-jpn/kakurenbo
An association function of Kakurenbo is better than other gems.
i wont use a default scope cause if i want to get all the records, i need to ignore it using "with_exclusive_scope" which in turn is messy. the would go by adding a 'deleted' boolean field which is set when the record is deleted. Also, would have added scopes to get the data as per the condition.
checkout Overriding a Rails default_scope and Rails: Why is with_exclusive_scope protected? Any good practice on how to use it?
Add one column say status in your table and on deletion of records update the value of column status to inactive.
and while fetching the records, add condition status != "inactive" in the query.
For Rails 4 don't use acts_as_paranoid (buggy for Rails 4), use paranoia. All you have to do is add a deleted_at timestamp column and include acts_as_paranoia in the model.
From there, just call destroy on the object and all other ActiveRecord relations and most other methods(like :count) automatically exclude the soft_deleted records.

showing only attributes that are not null in the Rails 3 as_json method

I'm using the as_json method heavily in a couple of models I have in a project I'm working on, and what I'm trying to do is to display those attributes on the fly ONLY if they are not nil/Null ... does anyone have any idea how to go about this?
You can override as_json:
# clean_to_json.rb
module CleanToJson
def as_json(options = nil)
super(options).tap do |json|
json.delete_if{|k,v| v.nil?}.as_json unless options.try(:delete, :null)
end
end
end
# foo.rb
class Foo < ActiveRecord::Base
include CleanToJson
end
Usage:
#foo.as_json # Only present attributes
#foo.as_json(:null => true) # All attributes (former behavior)

How to write a Rails mixin that spans across model, controller, and view

In an effort to reduce code duplication in my little Rails app, I've been working on getting common code between my models into it's own separate module, so far so good.
The model stuff is fairly easy, I just have to include the module at the beginning, e.g.:
class Iso < Sale
include Shared::TracksSerialNumberExtension
include Shared::OrderLines
extend Shared::Filtered
include Sendable::Model
validates_presence_of :customer
validates_associated :lines
owned_by :customer
def initialize( params = nil )
super
self.created_at ||= Time.now.to_date
end
def after_initialize
end
order_lines :despatched
# tracks_serial_numbers :items
sendable :customer
def created_at=( date )
write_attribute( :created_at, Chronic.parse( date ) )
end
end
This is working fine, now however, I'm going to have some controller and view code that's going to be common between these models as well, so far I have this for my sendable stuff:
# This is a module that is used for pages/forms that are can be "sent"
# either via fax, email, or printed.
module Sendable
module Model
def self.included( klass )
klass.extend ClassMethods
end
module ClassMethods
def sendable( class_to_send_to )
attr_accessor :fax_number,
:email_address,
:to_be_faxed,
:to_be_emailed,
:to_be_printed
#_class_sending_to ||= class_to_send_to
include InstanceMethods
end
def class_sending_to
#_class_sending_to
end
end # ClassMethods
module InstanceMethods
def after_initialize( )
super
self.to_be_faxed = false
self.to_be_emailed = false
self.to_be_printed = false
target_class = self.send( self.class.class_sending_to )
if !target_class.nil?
self.fax_number = target_class.send( :fax_number )
self.email_address = target_class.send( :email_address )
end
end
end
end # Module Model
end # Module Sendable
Basically I'm planning on just doing an include Sendable::Controller, and Sendable::View (or the equivalent) for the controller and the view, but, is there a cleaner way to do this? I 'm after a neat way to have a bunch of common code between my model, controller, and view.
Edit: Just to clarify, this just has to be shared across 2 or 3 models.
You could pluginize it (use script/generate plugin).
Then in your init.rb just do something like:
ActiveRecord::Base.send(:include, PluginName::Sendable)
ActionController::Base.send(:include, PluginName::SendableController)
And along with your self.included that should work just fine.
Check out some of the acts_* plugins, it's a pretty common pattern (http://github.com/technoweenie/acts_as_paranoid/tree/master/init.rb, check line 30)
If that code needs to get added to all models and all controllers, you could always do the following:
# maybe put this in environment.rb or in your module declaration
class ActiveRecord::Base
include Iso
end
# application.rb
class ApplicationController
include Iso
end
If you needed functions from this module available to the views, you could expose them individually with helper_method declarations in application.rb.
If you do go the plugin route, do check out Rails-Engines, which are intended to extend plugin semantics to Controllers and Views in a clear way.

Resources