sequel-audited 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|