sequel-audited 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,7 @@
1
+ # TODO's
2
+
3
+
4
+ 1. Add more tests with different models to ensure data is stored / ignored correctly during updates.
5
+
6
+
7
+ 2. Add demo website and host demo on Heroku
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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/env.test.sample ADDED
@@ -0,0 +1,7 @@
1
+ # NOTE: rename this file as '.env.test' and change YOURNAME or the full DB path(s) below
2
+
3
+ # Using PostgreSQL
4
+ DATABASE_URL="postgres://YOURNAME@localhost/sequel-audited-test"
5
+
6
+ # Using SQLite
7
+ # DATABASE_URL="sqlite://spec/sequel-audited-test.db"
@@ -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,11 @@
1
+
2
+ #
3
+ module Sequel
4
+
5
+ #
6
+ module Audited
7
+
8
+ VERSION = "0.2.0".freeze
9
+
10
+ end
11
+ 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