model_timeline 0.1.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/LICENSE +21 -0
- data/README.md +453 -0
- data/lib/model_timeline/configuration_error.rb +24 -0
- data/lib/model_timeline/controller_additions.rb +64 -0
- data/lib/model_timeline/generators/install_generator.rb +44 -0
- data/lib/model_timeline/generators/templates/migration.rb.tt +27 -0
- data/lib/model_timeline/railtie.rb +31 -0
- data/lib/model_timeline/rspec/matchers.rb +230 -0
- data/lib/model_timeline/rspec.rb +49 -0
- data/lib/model_timeline/timeline_entry.rb +60 -0
- data/lib/model_timeline/timelineable.rb +214 -0
- data/lib/model_timeline/version.rb +9 -0
- data/lib/model_timeline.rb +197 -0
- metadata +89 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelTimeline
|
|
4
|
+
module RSpec
|
|
5
|
+
# rubocop:disable Naming/PredicateName
|
|
6
|
+
# Custom RSpec matchers for testing model timeline entries
|
|
7
|
+
#
|
|
8
|
+
# These matchers help you test the timeline entries created by the ModelTimeline gem.
|
|
9
|
+
# Use them in your specs to verify that your models are correctly recording timeline events.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage with different matchers
|
|
12
|
+
# # Check for any timeline entries
|
|
13
|
+
# expect(user).to have_timeline_entries
|
|
14
|
+
#
|
|
15
|
+
# # Check for specific number of entries
|
|
16
|
+
# expect(user).to have_timeline_entries(3)
|
|
17
|
+
#
|
|
18
|
+
# # Check for specific action
|
|
19
|
+
# expect(user).to have_timelined_action(:update)
|
|
20
|
+
#
|
|
21
|
+
# # Check for changes to a specific attribute
|
|
22
|
+
# expect(user).to have_timelined_change(:email)
|
|
23
|
+
#
|
|
24
|
+
# # Check for specific value change
|
|
25
|
+
# expect(user).to have_timelined_entry(:status, "active")
|
|
26
|
+
module Matchers
|
|
27
|
+
# Check if a model has timeline entries
|
|
28
|
+
#
|
|
29
|
+
# @param count [Integer, nil] The expected number of timeline entries (optional)
|
|
30
|
+
# @return [HaveTimelineEntriesMatcher] A matcher that checks for timeline entries
|
|
31
|
+
# @example Without count parameter
|
|
32
|
+
# expect(user).to have_timeline_entries
|
|
33
|
+
# @example With count parameter
|
|
34
|
+
# expect(user).to have_timeline_entries(3)
|
|
35
|
+
def have_timeline_entries(count = nil)
|
|
36
|
+
HaveTimelineEntriesMatcher.new(count)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Check if a specific action was recorded in the timeline
|
|
40
|
+
#
|
|
41
|
+
# @param action [String, Symbol] The action name to look for
|
|
42
|
+
# @return [HaveTimelinedAction] A matcher that checks for a specific action
|
|
43
|
+
# @example
|
|
44
|
+
# expect(user).to have_timelined_action(:create)
|
|
45
|
+
# expect(user).to have_timelined_action("update")
|
|
46
|
+
def have_timelined_action(action)
|
|
47
|
+
HaveTimelinedAction.new(action)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if a specific attribute change was recorded in the timeline
|
|
51
|
+
#
|
|
52
|
+
# @param attribute [String, Symbol] The attribute name to check for changes
|
|
53
|
+
# @return [HaveTimelinedChange] A matcher that checks if an attribute was changed
|
|
54
|
+
# @example
|
|
55
|
+
# expect(user).to have_timelined_change(:email)
|
|
56
|
+
# expect(user).to have_timelined_change("status")
|
|
57
|
+
def have_timelined_change(attribute)
|
|
58
|
+
HaveTimelinedChange.new(attribute)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Check if an attribute was changed to a specific value in the timeline
|
|
62
|
+
#
|
|
63
|
+
# @param attribute [String, Symbol] The attribute name to check
|
|
64
|
+
# @param value [Object] The value to check for
|
|
65
|
+
# @return [HaveTimelinedEntry] A matcher that checks for specific attribute values
|
|
66
|
+
# @example
|
|
67
|
+
# expect(user).to have_timelined_entry(:status, "active")
|
|
68
|
+
# expect(user).to have_timelined_entry(:role, :admin)
|
|
69
|
+
def have_timelined_entry(attribute, value)
|
|
70
|
+
HaveTimelinedEntry.new(attribute, value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# RSpec matcher to check if a model has timeline entries
|
|
74
|
+
#
|
|
75
|
+
# @api private
|
|
76
|
+
class HaveTimelineEntriesMatcher
|
|
77
|
+
# Initialize the matcher
|
|
78
|
+
#
|
|
79
|
+
# @param expected_count [Integer, nil] The expected number of timeline entries
|
|
80
|
+
def initialize(expected_count)
|
|
81
|
+
@expected_count = expected_count
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if the subject matches the expectations
|
|
85
|
+
#
|
|
86
|
+
# @param subject [Object] The model to check for timeline entries
|
|
87
|
+
# @return [Boolean] True if the model has the expected number of timeline entries
|
|
88
|
+
def matches?(subject)
|
|
89
|
+
@subject = subject
|
|
90
|
+
if @expected_count.nil?
|
|
91
|
+
subject.timeline_entries.any?
|
|
92
|
+
else
|
|
93
|
+
subject.timeline_entries.count == @expected_count
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Message displayed when the expectation fails
|
|
98
|
+
#
|
|
99
|
+
# @return [String] A descriptive failure message
|
|
100
|
+
def failure_message
|
|
101
|
+
if @expected_count.nil?
|
|
102
|
+
"expected #{@subject} to have timeline entries, but found none"
|
|
103
|
+
else
|
|
104
|
+
"expected #{@subject} to have #{@expected_count} timeline entries, " \
|
|
105
|
+
"but found #{@subject.timeline_entries.count}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Message displayed when the negated expectation fails
|
|
110
|
+
#
|
|
111
|
+
# @return [String] A descriptive failure message for negated expectations
|
|
112
|
+
def failure_message_when_negated
|
|
113
|
+
if @expected_count.nil?
|
|
114
|
+
"expected #{@subject} not to have any timeline entries, but found #{@subject.timeline_entries.count}"
|
|
115
|
+
else
|
|
116
|
+
"expected #{@subject} not to have #{@expected_count} timeline entries, but found exactly that many"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# RSpec matcher to check if a model has timeline entries with a specific action
|
|
122
|
+
#
|
|
123
|
+
# @api private
|
|
124
|
+
class HaveTimelinedAction
|
|
125
|
+
# Initialize the matcher
|
|
126
|
+
#
|
|
127
|
+
# @param action [String, Symbol] The action to look for
|
|
128
|
+
def initialize(action)
|
|
129
|
+
@action = action.to_s
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Check if the subject matches the expectations
|
|
133
|
+
#
|
|
134
|
+
# @param subject [Object] The model to check for timeline entries
|
|
135
|
+
# @return [Boolean] True if the model has timeline entries with the specified action
|
|
136
|
+
def matches?(subject)
|
|
137
|
+
@subject = subject
|
|
138
|
+
subject.timeline_entries.where(action: @action).exists?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Message displayed when the expectation fails
|
|
142
|
+
#
|
|
143
|
+
# @return [String] A descriptive failure message
|
|
144
|
+
def failure_message
|
|
145
|
+
"expected #{@subject} to have recorded action '#{@action}', but none was found"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Message displayed when the negated expectation fails
|
|
149
|
+
#
|
|
150
|
+
# @return [String] A descriptive failure message for negated expectations
|
|
151
|
+
def failure_message_when_negated
|
|
152
|
+
"expected #{@subject} not to have recorded action '#{@action}', but it was found"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# RSpec matcher to check if a model has timeline entries with changes to a specific attribute
|
|
157
|
+
#
|
|
158
|
+
# @api private
|
|
159
|
+
class HaveTimelinedChange
|
|
160
|
+
# Initialize the matcher
|
|
161
|
+
#
|
|
162
|
+
# @param attribute [String, Symbol] The attribute to check for changes
|
|
163
|
+
def initialize(attribute)
|
|
164
|
+
@attribute = attribute.to_s
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Check if the subject matches the expectations
|
|
168
|
+
#
|
|
169
|
+
# @param subject [Object] The model to check for timeline entries
|
|
170
|
+
# @return [Boolean] True if the model has timeline entries with changes to the specified attribute
|
|
171
|
+
def matches?(subject)
|
|
172
|
+
@subject = subject
|
|
173
|
+
subject.timeline_entries.with_changed_attribute(@attribute).exists?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Message displayed when the expectation fails
|
|
177
|
+
#
|
|
178
|
+
# @return [String] A descriptive failure message
|
|
179
|
+
def failure_message
|
|
180
|
+
"expected #{@subject} to have tracked changes to '#{@attribute}', but none was found"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Message displayed when the negated expectation fails
|
|
184
|
+
#
|
|
185
|
+
# @return [String] A descriptive failure message for negated expectations
|
|
186
|
+
def failure_message_when_negated
|
|
187
|
+
"expected #{@subject} not to have tracked changes to '#{@attribute}', but changes were found"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# RSpec matcher to check if a model has timeline entries where an attribute changed to a specific value
|
|
192
|
+
#
|
|
193
|
+
# @api private
|
|
194
|
+
class HaveTimelinedEntry
|
|
195
|
+
# Initialize the matcher
|
|
196
|
+
#
|
|
197
|
+
# @param attribute [String, Symbol] The attribute to check
|
|
198
|
+
# @param value [Object] The value the attribute should have changed to
|
|
199
|
+
def initialize(attribute, value)
|
|
200
|
+
@attribute = attribute.to_s
|
|
201
|
+
@value = value
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Check if the subject matches the expectations
|
|
205
|
+
#
|
|
206
|
+
# @param subject [Object] The model to check for timeline entries
|
|
207
|
+
# @return [Boolean] True if the model has timeline entries where the attribute changed to the specified value
|
|
208
|
+
def matches?(subject)
|
|
209
|
+
@subject = subject
|
|
210
|
+
subject.timeline_entries.with_changed_value(@attribute, @value).exists?
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Message displayed when the expectation fails
|
|
214
|
+
#
|
|
215
|
+
# @return [String] A descriptive failure message
|
|
216
|
+
def failure_message
|
|
217
|
+
"expected #{@subject} to have tracked '#{@attribute}' changing to '#{@value}', but no such change was found"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Message displayed when the negated expectation fails
|
|
221
|
+
#
|
|
222
|
+
# @return [String] A descriptive failure message for negated expectations
|
|
223
|
+
def failure_message_when_negated
|
|
224
|
+
"expected #{@subject} not to have tracked '#{@attribute}' changing to '#{@value}', but such a change was found"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
# rubocop:enable Naming/PredicateName
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'model_timeline/rspec/matchers'
|
|
4
|
+
module ModelTimeline
|
|
5
|
+
# Helper module that configures RSpec to work with ModelTimeline
|
|
6
|
+
#
|
|
7
|
+
# This module provides RSpec configuration for ModelTimeline, including:
|
|
8
|
+
# - Disabling timeline recording by default for faster tests
|
|
9
|
+
# - Enabling timeline recording only when specifically requested
|
|
10
|
+
# - Including custom RSpec matchers for testing timeline entries
|
|
11
|
+
#
|
|
12
|
+
# @example Including in RSpec configuration
|
|
13
|
+
# # In spec_helper.rb or rails_helper.rb
|
|
14
|
+
# RSpec.configure do |config|
|
|
15
|
+
# config.include ModelTimelineHelper
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Running a test with timeline recording enabled
|
|
19
|
+
# # Use the :with_timeline metadata to enable recording
|
|
20
|
+
# describe User, :with_timeline do
|
|
21
|
+
# it "records timeline entries when updated" do
|
|
22
|
+
# user.update(name: "New Name")
|
|
23
|
+
# expect(user).to have_timelined_change(:name)
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
module RSpec
|
|
27
|
+
# Configures RSpec with ModelTimeline hooks when included
|
|
28
|
+
#
|
|
29
|
+
# @param config [RSpec::Core::Configuration] The RSpec configuration object
|
|
30
|
+
# @return [void]
|
|
31
|
+
def self.included(config)
|
|
32
|
+
# Reset timeline state before each example
|
|
33
|
+
config.before(:each) do |example|
|
|
34
|
+
ModelTimeline.disable! unless example.metadata[:with_timeline]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Enable timeline when the :with_timeline metadata is present
|
|
38
|
+
config.around(:each, :with_timeline) do |example|
|
|
39
|
+
ModelTimeline.enable!
|
|
40
|
+
example.run
|
|
41
|
+
ensure
|
|
42
|
+
ModelTimeline.disable!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Include custom RSpec matchers for testing timeline entries
|
|
46
|
+
config.include ModelTimeline::RSpec::Matchers
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelTimeline
|
|
4
|
+
# Represents a timeline entry that records changes to a model.
|
|
5
|
+
# TimelineEntry stores the tracked object, user who made the change,
|
|
6
|
+
# IP address, and the changes that were made.
|
|
7
|
+
#
|
|
8
|
+
class TimelineEntry < ActiveRecord::Base
|
|
9
|
+
self.table_name = 'model_timeline_timeline_entries'
|
|
10
|
+
|
|
11
|
+
# @!attribute timelineable
|
|
12
|
+
# @return [Object] The model instance that this timeline entry belongs to
|
|
13
|
+
belongs_to :timelineable, polymorphic: true, optional: true
|
|
14
|
+
|
|
15
|
+
# @!attribute user
|
|
16
|
+
# @return [Object] The user who made the change
|
|
17
|
+
belongs_to :user, polymorphic: true, optional: true
|
|
18
|
+
|
|
19
|
+
# Retrieves timeline entries for a specific timelineable object
|
|
20
|
+
#
|
|
21
|
+
# @param [Object] timelineable The object to find timeline entries for
|
|
22
|
+
# @return [ActiveRecord::Relation] Timeline entries for the specified object
|
|
23
|
+
def self.for_timelineable(timelineable)
|
|
24
|
+
where(timelineable_type: timelineable.class.name, timelineable_id: timelineable.id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Retrieves timeline entries created by a specific user
|
|
28
|
+
#
|
|
29
|
+
# @param [Object] user The user who created the timeline entries
|
|
30
|
+
# @return [ActiveRecord::Relation] Timeline entries created by the specified user
|
|
31
|
+
def self.for_user(user)
|
|
32
|
+
where(user_type: user.class.name, user_id: user.id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Retrieves timeline entries from a specific IP address
|
|
36
|
+
#
|
|
37
|
+
# @param [String] ip The IP address to search for
|
|
38
|
+
# @return [ActiveRecord::Relation] Timeline entries from the specified IP address
|
|
39
|
+
def self.for_ip_address(ip)
|
|
40
|
+
where(ip_address: ip)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Retrieves timeline entries where a specific attribute was changed
|
|
44
|
+
#
|
|
45
|
+
# @param [Symbol, String] attribute The attribute name to check for changes
|
|
46
|
+
# @return [ActiveRecord::Relation] Timeline entries where the specified attribute changed
|
|
47
|
+
def self.with_changed_attribute(attribute)
|
|
48
|
+
where('object_changes ? :key', key: attribute.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Retrieves timeline entries where an attribute was changed to a specific value
|
|
52
|
+
#
|
|
53
|
+
# @param [Symbol, String] attribute The attribute name to check
|
|
54
|
+
# @param [Object] value The value the attribute was changed to
|
|
55
|
+
# @return [ActiveRecord::Relation] Timeline entries matching the attribute and value change
|
|
56
|
+
def self.with_changed_value(attribute, value)
|
|
57
|
+
where('object_changes -> :key ->> 1 = :value', key: attribute.to_s, value: value.to_s)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelTimeline
|
|
4
|
+
# Provides timeline tracking functionality for ActiveRecord models.
|
|
5
|
+
# When included in a model, this module allows tracking changes to model attributes
|
|
6
|
+
# over time by creating timeline entries.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# class User < ApplicationRecord
|
|
10
|
+
# has_timeline
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# @example With custom options
|
|
14
|
+
# class Product < ApplicationRecord
|
|
15
|
+
# has_timeline :product_history,
|
|
16
|
+
# only: [:name, :price],
|
|
17
|
+
# on: [:update, :destroy]
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
module Timelineable
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
included do
|
|
24
|
+
class_attribute :loggers, default: {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Methods that will be added as class methods to the including class
|
|
28
|
+
class_methods do
|
|
29
|
+
# Enables timeline tracking for the model.
|
|
30
|
+
# This method configures the model to record timeline entries on various
|
|
31
|
+
# lifecycle events (create, update, destroy).
|
|
32
|
+
#
|
|
33
|
+
# @param association_name [Symbol] The name for the timeline entries association
|
|
34
|
+
# @param options [Hash] Configuration options
|
|
35
|
+
# @option options [Array<Symbol>] :on ([:create, :update, :destroy]) Which events to track
|
|
36
|
+
# @option options [Array<Symbol>] :only Limit tracking to these attributes only
|
|
37
|
+
# @option options [Array<Symbol>] :ignore ([]) Attributes to exclude from tracking
|
|
38
|
+
# @option options [String] :class_name ('ModelTimeline::TimelineEntry') The timeline entry class
|
|
39
|
+
# @option options [Hash] :meta ({}) Additional metadata to include with each entry
|
|
40
|
+
# @return [void]
|
|
41
|
+
# @raise [ModelTimeline::ConfigurationError] If timeline has already been configured
|
|
42
|
+
#
|
|
43
|
+
# @example Track all changes
|
|
44
|
+
# has_timeline
|
|
45
|
+
#
|
|
46
|
+
# @example Track only specific attributes
|
|
47
|
+
# has_timeline(only: [:name, :status])
|
|
48
|
+
#
|
|
49
|
+
# @example Use a custom association name
|
|
50
|
+
# has_timeline(:audit_log)
|
|
51
|
+
#
|
|
52
|
+
# @example Add metadata to entries
|
|
53
|
+
# has_timeline(meta: { app_version: APP_VERSION })
|
|
54
|
+
#
|
|
55
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
56
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
|
57
|
+
# rubocop:disable Naming/PredicateName
|
|
58
|
+
def has_timeline(*args, **kwargs)
|
|
59
|
+
association_name = args.first.is_a?(Symbol) ? args.shift : :timeline_entries
|
|
60
|
+
|
|
61
|
+
klass = (kwargs[:class_name] || 'ModelTimeline::TimelineEntry').constantize
|
|
62
|
+
|
|
63
|
+
config = {
|
|
64
|
+
on: kwargs[:on] || %i[create update destroy],
|
|
65
|
+
only: kwargs[:only],
|
|
66
|
+
ignore: kwargs[:ignore] || [],
|
|
67
|
+
klass: klass,
|
|
68
|
+
meta: kwargs[:meta] || {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
config_key = "#{to_s.underscore}-#{klass}"
|
|
72
|
+
raise ::ModelTimeline::ConfigurationError if loggers[config_key].present?
|
|
73
|
+
|
|
74
|
+
loggers[config_key] = config
|
|
75
|
+
|
|
76
|
+
after_save -> { log_after_save(config_key) } if config[:on].include?(:create) || config[:on].include?(:update)
|
|
77
|
+
|
|
78
|
+
after_destroy -> { log_audit_deletion(config_key) } if config[:on].include?(:destroy)
|
|
79
|
+
|
|
80
|
+
has_many association_name.to_sym, class_name: klass.name, as: :timelineable
|
|
81
|
+
end
|
|
82
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
83
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
|
84
|
+
# rubocop:enable Naming/PredicateName
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Records timeline entries after a model is saved (create or update)
|
|
90
|
+
#
|
|
91
|
+
# @param config_key [String] The configuration key for this model
|
|
92
|
+
# @return [void]
|
|
93
|
+
def log_after_save(config_key)
|
|
94
|
+
return unless ModelTimeline.enabled?
|
|
95
|
+
|
|
96
|
+
config = self.class.loggers[config_key]
|
|
97
|
+
return unless config
|
|
98
|
+
|
|
99
|
+
object_changes = filter_attributes(previous_changes, config)
|
|
100
|
+
return if object_changes.empty?
|
|
101
|
+
|
|
102
|
+
action = previously_new_record? ? :create : :update
|
|
103
|
+
return unless config[:on].include?(action)
|
|
104
|
+
|
|
105
|
+
config[:klass].create!(
|
|
106
|
+
timelineable_type: self.class.name,
|
|
107
|
+
timelineable_id: id,
|
|
108
|
+
action: action,
|
|
109
|
+
object_changes: object_changes,
|
|
110
|
+
ip_address: current_ip_address,
|
|
111
|
+
**current_user_attributes,
|
|
112
|
+
**collect_metadata(config[:meta], config_key)
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Records timeline entries after a model is destroyed
|
|
117
|
+
#
|
|
118
|
+
# @param config_key [String] The configuration key for this model
|
|
119
|
+
# @return [void]
|
|
120
|
+
def log_audit_deletion(config_key)
|
|
121
|
+
return unless ModelTimeline.enabled?
|
|
122
|
+
|
|
123
|
+
config = self.class.loggers[config_key]
|
|
124
|
+
return unless config
|
|
125
|
+
|
|
126
|
+
config[:klass].create!(
|
|
127
|
+
timelineable_type: self.class.name,
|
|
128
|
+
timelineable_id: id,
|
|
129
|
+
action: 'destroy',
|
|
130
|
+
object_changes: {},
|
|
131
|
+
ip_address: current_ip_address,
|
|
132
|
+
**current_user_attributes,
|
|
133
|
+
**collect_metadata(config[:meta], config_key)
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Gets the current user ID if available
|
|
138
|
+
#
|
|
139
|
+
# @return [Integer, nil] The current user ID or nil if not available
|
|
140
|
+
def current_user_id
|
|
141
|
+
return unless respond_to?(ModelTimeline.current_user_method)
|
|
142
|
+
|
|
143
|
+
user = send(ModelTimeline.current_user_method)
|
|
144
|
+
user.respond_to?(:id) ? user.id : nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Gets the current IP address from the request store
|
|
148
|
+
#
|
|
149
|
+
# @return [String, nil] The current IP address or nil if not available
|
|
150
|
+
def current_ip_address
|
|
151
|
+
ModelTimeline.current_ip
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Prepares user attributes for the timeline entry
|
|
155
|
+
#
|
|
156
|
+
# @return [Hash] User attributes for the timeline entry
|
|
157
|
+
def current_user_attributes
|
|
158
|
+
user = ModelTimeline.current_user
|
|
159
|
+
return {} if user.nil?
|
|
160
|
+
|
|
161
|
+
return { username: user.to_s } if user.is_a?(String) || user.is_a?(Symbol)
|
|
162
|
+
|
|
163
|
+
return { user_id: user.id, user_type: user.class.name } if user.respond_to?(:id) &&
|
|
164
|
+
user.class.ancestors.include?(ActiveRecord::Base)
|
|
165
|
+
|
|
166
|
+
return { username: user.to_s } if user.respond_to?(:to_s)
|
|
167
|
+
|
|
168
|
+
{}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Filters attributes based on configuration
|
|
172
|
+
#
|
|
173
|
+
# @param attrs [Hash] The attributes to filter
|
|
174
|
+
# @param config [Hash] The timeline configuration
|
|
175
|
+
# @return [Hash] Filtered attributes
|
|
176
|
+
def filter_attributes(attrs, config)
|
|
177
|
+
result = attrs.dup.with_indifferent_access
|
|
178
|
+
result = result.slice(*config[:only]) if config[:only].present?
|
|
179
|
+
result = result.except(*config[:ignore]) if config[:ignore].present?
|
|
180
|
+
|
|
181
|
+
result
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Collects metadata for the timeline entry
|
|
185
|
+
#
|
|
186
|
+
# @param meta_config [Hash] The metadata configuration
|
|
187
|
+
# @param config_key [String] The configuration key for this model
|
|
188
|
+
# @return [Hash] Collected metadata
|
|
189
|
+
def collect_metadata(meta_config, config_key)
|
|
190
|
+
config = self.class.loggers[config_key]
|
|
191
|
+
metadata = {}
|
|
192
|
+
|
|
193
|
+
# First, add any thread-level metadata
|
|
194
|
+
metadata.merge!(ModelTimeline.metadata)
|
|
195
|
+
|
|
196
|
+
# Then, add any model-specific metadata defined in config
|
|
197
|
+
meta_config.each do |key, value|
|
|
198
|
+
resolved_value = case value
|
|
199
|
+
when Proc
|
|
200
|
+
instance_exec(self, &value)
|
|
201
|
+
when Symbol
|
|
202
|
+
respond_to?(value) ? send(value) : value
|
|
203
|
+
else
|
|
204
|
+
value
|
|
205
|
+
end
|
|
206
|
+
metadata[key] = resolved_value
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Only include keys that exist as columns in the timeline entry table
|
|
210
|
+
column_names = config[:klass].column_names.map(&:to_sym)
|
|
211
|
+
metadata.slice(*column_names)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|