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.
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