sequel-audited 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +659 -0
- data/Rakefile +32 -0
- data/TODOs.md +7 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/env.test.sample +7 -0
- data/lib/sequel/audited.rb +29 -0
- data/lib/sequel/audited/version.rb +11 -0
- data/lib/sequel/plugins/audited.rb +290 -0
- data/lib/tasks/sequel-audited/migrate.rake +39 -0
- data/lib/tasks/sequel-audited/templates/audited_migration.rb +42 -0
- data/sequel-audited.gemspec +47 -0
- metadata +233 -0
data/Rakefile
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
2
|
+
require "rake/testtask"
|
3
|
+
#
|
4
|
+
#
|
5
|
+
OSX = RUBY_PLATFORM.match(/darwin/)
|
6
|
+
|
7
|
+
Rake::TestTask.new(:spec) do |t|
|
8
|
+
t.libs << "spec"
|
9
|
+
t.libs << "lib"
|
10
|
+
t.test_files = FileList["spec/**/*_spec.rb"]
|
11
|
+
end
|
12
|
+
|
13
|
+
task :default => :spec
|
14
|
+
task :test => :spec
|
15
|
+
|
16
|
+
desc "Run specs with coverage"
|
17
|
+
task :coverage do
|
18
|
+
ENV["COVERAGE"] = "1"
|
19
|
+
Rake::Task["spec"].invoke
|
20
|
+
`open coverage/index.html` if OSX
|
21
|
+
end
|
22
|
+
|
23
|
+
desc "Run Rubocop report"
|
24
|
+
task :rubocop do
|
25
|
+
res = `which rubocop`
|
26
|
+
if res != ""
|
27
|
+
`rubocop -f html -o ./rubocop/report.html lib/`
|
28
|
+
`open rubocop/report.html` if OSX
|
29
|
+
else
|
30
|
+
puts "\nERROR: 'rubocop' gem is not installed or available. Please run 'gem install rubocop'."
|
31
|
+
end
|
32
|
+
end
|
data/TODOs.md
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "sequel/audited"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/env.test.sample
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
require "sequel"
|
2
|
+
require "sequel/audited/version"
|
3
|
+
|
4
|
+
module Sequel
|
5
|
+
|
6
|
+
#
|
7
|
+
module Audited
|
8
|
+
|
9
|
+
# set the name of the global method that provides the current user. Default: :current_user
|
10
|
+
@audited_current_user_method = :current_user
|
11
|
+
# enable swapping of the Audit model
|
12
|
+
@audited_model_name = :AuditLog
|
13
|
+
# toggle for enabling / disabling auditing
|
14
|
+
@audited_enabled = true
|
15
|
+
|
16
|
+
# by default ignore these columns
|
17
|
+
@audited_default_ignored_columns = [
|
18
|
+
# :id, :ref, :password, :password_hash,
|
19
|
+
:lock_version,
|
20
|
+
:created_at, :updated_at, :created_on, :updated_on
|
21
|
+
]
|
22
|
+
|
23
|
+
class << self
|
24
|
+
attr_accessor :audited_current_user_method, :audited_model_name,
|
25
|
+
:audited_enabled, :audited_default_ignored_columns
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,290 @@
|
|
1
|
+
require "ostruct"
|
2
|
+
|
3
|
+
# the versioning model
|
4
|
+
class AuditLog < Sequel::Model
|
5
|
+
# handle versioning of audited records based upon object uuid
|
6
|
+
plugin :list, field: :version, scope: [:item_uuid]
|
7
|
+
plugin :timestamps
|
8
|
+
|
9
|
+
def before_validation
|
10
|
+
# grab the current user
|
11
|
+
if u = audit_user
|
12
|
+
self.user_id = u.id
|
13
|
+
self.username = u.username
|
14
|
+
self.user_type = u.class.name ||= :User
|
15
|
+
end
|
16
|
+
super
|
17
|
+
end
|
18
|
+
|
19
|
+
# private
|
20
|
+
|
21
|
+
# Obtains the `current_user` based upon the `:audited_current_user_method' value set in the
|
22
|
+
# audited model, either via defaults or via :user_method config options
|
23
|
+
#
|
24
|
+
# NOTE! this allows overriding the default value on a per audited model
|
25
|
+
def audit_user
|
26
|
+
m = Kernel.const_get(item_type)
|
27
|
+
send(m.audited_current_user_method)
|
28
|
+
rescue NoMethodError
|
29
|
+
OpenStruct.new(id: "394d9d14-0c8c-4711-96c1-2c3fc90dd671", username: "system", name: "System Migration")
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
module Sequel
|
35
|
+
|
36
|
+
#
|
37
|
+
module Plugins
|
38
|
+
|
39
|
+
# Given a Post model with these fields:
|
40
|
+
# [:id, :category_id, :title, :body, :author_id, :created_at, :updated_at]
|
41
|
+
#
|
42
|
+
#
|
43
|
+
# All fields
|
44
|
+
# plugin :audited
|
45
|
+
# #=> [:category_id, :title, :body, :author_id] # NB! excluding @default_ignore_attrs
|
46
|
+
# #=> [:id, :created_at, :updated_at]
|
47
|
+
#
|
48
|
+
# Single field
|
49
|
+
# plugin :audited, only: :title
|
50
|
+
# plugin :audited, only: [:title]
|
51
|
+
# #=> [:title]
|
52
|
+
# #+> [:id, :category_id, :body, :author_id, :created_at, :updated_at] # ignored fields
|
53
|
+
#
|
54
|
+
# Multiple fields
|
55
|
+
# plugin :audited, only: [:title, :body]
|
56
|
+
# #=> [:title, :body] # tracked fields
|
57
|
+
# #=> [:id, :category_id, :author_id, :created_at, :updated_at] # ignored fields
|
58
|
+
#
|
59
|
+
#
|
60
|
+
# All fields except certain fields
|
61
|
+
# plugin :audited, except: :title
|
62
|
+
# plugin :audited, except: [:title]
|
63
|
+
# #=> [:id, :category_id, :author_id, :created_at, :updated_at] # tracked fields
|
64
|
+
# #=> [:title] # ignored fields
|
65
|
+
#
|
66
|
+
#
|
67
|
+
#
|
68
|
+
module Audited
|
69
|
+
|
70
|
+
# called by the model it is included into:
|
71
|
+
#
|
72
|
+
# Post.plugin :audited
|
73
|
+
#
|
74
|
+
def self.configure(model, opts = {})
|
75
|
+
model.instance_eval do
|
76
|
+
# add support for :dirty attributes tracking
|
77
|
+
plugin(:dirty)
|
78
|
+
|
79
|
+
# set the default ignored columns or revert to defaults
|
80
|
+
set_default_ignored_columns(opts)
|
81
|
+
# sets the name of the current User method or revert to default: :current_user
|
82
|
+
# specifically for the audited model on a per model basis
|
83
|
+
set_user_method(opts)
|
84
|
+
|
85
|
+
only = opts.fetch(:only, [])
|
86
|
+
except = opts.fetch(:except, [])
|
87
|
+
|
88
|
+
unless only.empty?
|
89
|
+
# we should only track the provided column
|
90
|
+
included_columns = [only].flatten
|
91
|
+
# subtract the 'only' columns from all columns to get excluded_columns
|
92
|
+
excluded_columns = columns - included_columns
|
93
|
+
else # except:
|
94
|
+
# all columns minus any excepted columns and default ignored columns
|
95
|
+
included_columns = [
|
96
|
+
[columns - [except].flatten].flatten - @audited_default_ignored_columns
|
97
|
+
].flatten.uniq
|
98
|
+
|
99
|
+
# except_columns = except.empty? ? [] : [except].flatten
|
100
|
+
excluded_columns = [columns - included_columns].flatten.uniq
|
101
|
+
# excluded_columns = [columns - [except_columns, included_columns].flatten].flatten.uniq
|
102
|
+
end
|
103
|
+
|
104
|
+
@audited_included_columns = included_columns
|
105
|
+
@audited_ignored_columns = excluded_columns
|
106
|
+
|
107
|
+
# create versions association
|
108
|
+
one_to_many :versions, class: audit_model_name, key: :item_uuid, primary_key: :id
|
109
|
+
end # /.instance_eval
|
110
|
+
end # /.configure
|
111
|
+
|
112
|
+
#
|
113
|
+
module ClassMethods
|
114
|
+
|
115
|
+
attr_accessor :audited_default_ignored_columns, :audited_current_user_method
|
116
|
+
# The holder of ignored columns
|
117
|
+
attr_reader :audited_ignored_columns
|
118
|
+
# The holder of columns that should be audited
|
119
|
+
attr_reader :audited_included_columns
|
120
|
+
|
121
|
+
Plugins.inherited_instance_variables(self,
|
122
|
+
:@audited_default_ignored_columns => nil,
|
123
|
+
:@audited_current_user_method => nil,
|
124
|
+
:@audited_included_columns => nil,
|
125
|
+
:@audited_ignored_columns => nil)
|
126
|
+
|
127
|
+
#
|
128
|
+
def non_audited_columns
|
129
|
+
columns - audited_columns
|
130
|
+
end
|
131
|
+
|
132
|
+
#
|
133
|
+
def audited_columns
|
134
|
+
@audited_columns ||= columns - @audited_ignored_columns
|
135
|
+
end
|
136
|
+
|
137
|
+
# returns true / false if any audits have been made
|
138
|
+
#
|
139
|
+
# Post.audited_versions? #=> true / false
|
140
|
+
#
|
141
|
+
def audited_versions?
|
142
|
+
audit_model.where(item_type: name.to_s).count >= 1
|
143
|
+
end
|
144
|
+
|
145
|
+
# grab all audits for a particular model based upon filters
|
146
|
+
#
|
147
|
+
# Posts.audited_versions(:model_pk => 123)
|
148
|
+
# #=> filtered by primary_key value
|
149
|
+
#
|
150
|
+
# Posts.audited_versions(:user_id => 88)
|
151
|
+
# #=> filtered by user name
|
152
|
+
#
|
153
|
+
# Posts.audited_versions(:created_at < Date.today - 2)
|
154
|
+
# #=> filtered to last two (2) days only
|
155
|
+
#
|
156
|
+
# Posts.audited_versions(:created_at > Date.today - 7)
|
157
|
+
# #=> filtered to older than last seven (7) days
|
158
|
+
#
|
159
|
+
def audited_versions(opts = {})
|
160
|
+
audit_model.where(opts.merge(item_type: name.to_s)).order(:item_uuid, :version).all
|
161
|
+
end
|
162
|
+
|
163
|
+
private
|
164
|
+
|
165
|
+
#
|
166
|
+
def audit_model
|
167
|
+
const_get(audit_model_name)
|
168
|
+
end
|
169
|
+
|
170
|
+
#
|
171
|
+
def audit_model_name
|
172
|
+
::Sequel::Audited.audited_model_name
|
173
|
+
end
|
174
|
+
|
175
|
+
#
|
176
|
+
def set_default_ignored_columns(opts)
|
177
|
+
if opts[:default_ignored_columns]
|
178
|
+
@audited_default_ignored_columns = opts[:default_ignored_columns]
|
179
|
+
else
|
180
|
+
@audited_default_ignored_columns = ::Sequel::Audited.audited_default_ignored_columns
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
#
|
185
|
+
def set_user_method(opts)
|
186
|
+
if opts[:user_method]
|
187
|
+
@audited_current_user_method = opts[:user_method]
|
188
|
+
else
|
189
|
+
@audited_current_user_method = ::Sequel::Audited.audited_current_user_method
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
end
|
194
|
+
|
195
|
+
#
|
196
|
+
module InstanceMethods
|
197
|
+
|
198
|
+
# def model_pk
|
199
|
+
# changed['model_pk']
|
200
|
+
# end
|
201
|
+
|
202
|
+
# Returns who put the post into its current state.
|
203
|
+
#
|
204
|
+
# post.blame # => 'joeblogs'
|
205
|
+
#
|
206
|
+
# post.last_audited_by # => 'joeblogs'
|
207
|
+
#
|
208
|
+
# Note! returns 'not audited' if there's no audited version (new unsaved record)
|
209
|
+
#
|
210
|
+
def blame
|
211
|
+
v = versions.last unless versions.empty?
|
212
|
+
v ? v.username : "not audited"
|
213
|
+
end
|
214
|
+
alias_method :last_audited_by, :blame
|
215
|
+
|
216
|
+
# Returns who put the post into its current state.
|
217
|
+
#
|
218
|
+
# post.last_audited_at # => '2015-12-19 @ 08:24:45'
|
219
|
+
#
|
220
|
+
# post.last_audited_on # => 'joeblogs'
|
221
|
+
#
|
222
|
+
# Note! returns 'not audited' if there's no audited version (new unsaved record)
|
223
|
+
#
|
224
|
+
def last_audited_at
|
225
|
+
v = versions.last unless versions.empty?
|
226
|
+
v ? v.created_at : "not audited"
|
227
|
+
end
|
228
|
+
alias_method :last_audited_on, :last_audited_at
|
229
|
+
|
230
|
+
private
|
231
|
+
|
232
|
+
#
|
233
|
+
def audited_json(event)
|
234
|
+
case event
|
235
|
+
when "create"
|
236
|
+
# store all values on create
|
237
|
+
self.values.to_json
|
238
|
+
when "update"
|
239
|
+
# store only audited columns (skip ignored columns)
|
240
|
+
cols_changed = column_changes.empty? ? previous_changes : column_changes
|
241
|
+
changes = {}
|
242
|
+
cols_changed.keys.each do |ck|
|
243
|
+
changes[ck.to_sym] = cols_changed[ck.to_sym] if self.class.audited_columns.include?(ck.to_sym)
|
244
|
+
end
|
245
|
+
# pass nil if no changes
|
246
|
+
changes.empty? ? nil : changes.to_json
|
247
|
+
when "destroy"
|
248
|
+
# store all values on destroy
|
249
|
+
self.values.to_json
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
#
|
254
|
+
def add_audited(event)
|
255
|
+
changed_items = audited_json(event)
|
256
|
+
unless changed_items.blank?
|
257
|
+
add_version(
|
258
|
+
item_type: model,
|
259
|
+
item_uuid: pk,
|
260
|
+
event: event,
|
261
|
+
changed: changed_items
|
262
|
+
)
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
|
267
|
+
### CALLBACKS ###
|
268
|
+
|
269
|
+
def after_create
|
270
|
+
super
|
271
|
+
add_audited("create")
|
272
|
+
end
|
273
|
+
|
274
|
+
def after_update
|
275
|
+
super
|
276
|
+
add_audited("update")
|
277
|
+
end
|
278
|
+
|
279
|
+
def after_destroy
|
280
|
+
super
|
281
|
+
add_audited("destroy")
|
282
|
+
end
|
283
|
+
|
284
|
+
end
|
285
|
+
|
286
|
+
end
|
287
|
+
|
288
|
+
end
|
289
|
+
|
290
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# require "fileutils"
|
2
|
+
|
3
|
+
namespace :audited do
|
4
|
+
|
5
|
+
namespace :migrate do
|
6
|
+
|
7
|
+
desc "Installs Sequel::Audited migration, but does not run it"
|
8
|
+
task :install, [:path] do |_t, args|
|
9
|
+
tmp_path = args.path ? args.path : Dir.pwd
|
10
|
+
# get the last migration file and extrac the file name only
|
11
|
+
num = extract_next_migration_number("#{tmp_path}/db/migrate")
|
12
|
+
FileUtils.cp(
|
13
|
+
"#{File.dirname(__FILE__)}/templates/audited_migration.rb",
|
14
|
+
"#{tmp_path}/db/migrate/#{num}_create_auditlog_table.rb"
|
15
|
+
)
|
16
|
+
end
|
17
|
+
|
18
|
+
desc "Updates existing Sequel::Audited migration files with amendments"
|
19
|
+
task :update do
|
20
|
+
puts "TODO: no updates required yet"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def extract_next_migration_number(migrations_path)
|
25
|
+
# grab all the migration files or return empty array
|
26
|
+
mfs = Dir["#{migrations_path}/*.rb"]
|
27
|
+
# test for migrations or empty array
|
28
|
+
if mfs.empty?
|
29
|
+
num = "001"
|
30
|
+
else
|
31
|
+
lmf = File.basename(mfs.sort.last) # extract base name of the last migration file after sorting
|
32
|
+
num = lmf[0, 3] # extract the first 3 digits of the file
|
33
|
+
num = num.to_i + 1 # convert to integer and increment by 1
|
34
|
+
num = num.to_s.rjust(3, "0") # left-pad with zero if required
|
35
|
+
end
|
36
|
+
num
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|