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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7406b06646ef46c0b8d2541835ebeaa5e0f71638c600d0b7de45903bb36b2c81
|
|
4
|
+
data.tar.gz: dad655ebda19102a5e09eb3a51d07ac15cfbca051527ee3c3a71d979ab2a4543
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4f618bea0cc20328d5d2a61352c753ec39fbe04f90b7f909ba93df236ab28a4e239515c3b0b70913381149e99996c189ebb41afe5e2a2d6649e3fdb0fabf24b4
|
|
7
|
+
data.tar.gz: d145d2798077f4bbb13191f966d8b73433330e5360b808862f14ddc3f1e4d7e6d97d231ad9e0828a5f63f4f759453550eb5cb132781a9a5dfa15b21b816ec36b
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Alexandre Stapenhorst
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# ModelTimeline
|
|
2
|
+
|
|
3
|
+
ModelTimeline is a flexible audit logging gem for Rails applications that allows you to track changes to your models with comprehensive attribution and flexible configuration options.
|
|
4
|
+
|
|
5
|
+
## How this gem is different than paper_trail and audited?
|
|
6
|
+
|
|
7
|
+
ModelTimeline was designed with several unique features that differentiate it from other auditing gems:
|
|
8
|
+
|
|
9
|
+
- *Multiple configurations per model:* Unlike paper_trail and audited, ModelTimeline allows you to define multiple timeline configurations on the same model. This means you can track different sets of attributes for different purposes.
|
|
10
|
+
- *Targeted tracking:* Configure separate timelines for different aspects of your model (e.g., one for security events, another for content changes).
|
|
11
|
+
- PostgreSQL optimization: Built to leverage PostgreSQL's JSONB capabilities for efficient storage and advanced querying.
|
|
12
|
+
- *IP address tracking:* Automatically captures the client IP address for each change.
|
|
13
|
+
- *Rich metadata support:* Add custom metadata to timeline entries via configuration or at runtime.
|
|
14
|
+
- *Flexible user attribution:* Works with any authentication system by using a configurable method to retrieve the current user.
|
|
15
|
+
- *Comprehensive RSpec support:* Built-in matchers for testing timeline recording.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Add this line to your application's Gemfile:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
gem 'model_timeline'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
And then execute:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
$ bundle install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or install it yourself as:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
$ gem install model_timeline
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Run the generator to create the necessary migration:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
$ rails generate model_timeline:install
|
|
42
|
+
$ rails db:migrate
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For a custom table name:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
$ rails generate model_timeline:install --table_name=custom_timeline_entries
|
|
49
|
+
$ rails db:migrate
|
|
50
|
+
```
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
### Initializer
|
|
54
|
+
|
|
55
|
+
Configure the gem in an initializer:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# config/initializers/model_timeline.rb
|
|
59
|
+
ModelTimeline.configure do |config|
|
|
60
|
+
# Method to retrieve the current user in controllers (default: :current_user)
|
|
61
|
+
config.current_user_method = :current_user
|
|
62
|
+
|
|
63
|
+
# Method to retrieve the client IP address in controllers (default: :remote_ip)
|
|
64
|
+
config.current_ip_method = :remote_ip
|
|
65
|
+
|
|
66
|
+
# Enable/disable timeline tracking globally
|
|
67
|
+
# config.enabled = true # Enabled by default
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Model Configuration
|
|
72
|
+
Include the Timelineable module in your models (this happens automatically with Rails):
|
|
73
|
+
|
|
74
|
+
> **Important**: When defining multiple timelines on the same model, each must use a unique `class_name` option. Otherwise, the associations will conflict and an error will be raised.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# Basic usage with default settings
|
|
78
|
+
class User < ApplicationRecord
|
|
79
|
+
has_timeline
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Using a custom association name with class_name along default
|
|
83
|
+
class User < ApplicationRecord
|
|
84
|
+
has_timeline
|
|
85
|
+
|
|
86
|
+
has_timeline :security_events, class_name: 'SecurityTimelineEntry'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Tracking only specific attributes
|
|
90
|
+
class User < ApplicationRecord
|
|
91
|
+
has_timeline only: [:last_login_at, :login_count, :status],
|
|
92
|
+
class_name: 'LoginTimelineEntry'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Ignoring specific attributes
|
|
96
|
+
class User < ApplicationRecord
|
|
97
|
+
has_timeline :profile_changes,
|
|
98
|
+
ignore: [:password, :remember_token, :login_count],
|
|
99
|
+
class_name: 'ProfileTimelineEntry'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Tracking only specific events
|
|
103
|
+
class User < ApplicationRecord
|
|
104
|
+
has_timeline :content_changes,
|
|
105
|
+
on: [:update, :destroy],
|
|
106
|
+
class_name: 'ContentTimelineEntry'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Using a custom timeline entry class and table
|
|
110
|
+
class User < ApplicationRecord
|
|
111
|
+
has_timeline :custom_timeline_entries,
|
|
112
|
+
class_name: 'CustomTimelineEntry'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Adding additional metadata to each entry
|
|
116
|
+
class Order < ApplicationRecord
|
|
117
|
+
has_timeline :admin_changes,
|
|
118
|
+
class_name: 'AdminTimelineEntry',
|
|
119
|
+
meta: {
|
|
120
|
+
app_version: "1.0",
|
|
121
|
+
# Dynamic values using methods or procs
|
|
122
|
+
section: :section_name,
|
|
123
|
+
category_id: ->(record) { record.category_id }
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Using Metadata
|
|
129
|
+
|
|
130
|
+
<!-- ! THIS IS WRONG, NEEDS TO BE UPDATED. -->
|
|
131
|
+
ModelTimeline allows you to include custom metadata with your timeline entries, which is especially useful for tracking changes across related entities or adding domain-specific context.
|
|
132
|
+
|
|
133
|
+
#### Adding Metadata Through Configuration
|
|
134
|
+
|
|
135
|
+
When defining a timeline, any fields you include in the `meta` option will be evaluated and stored in the timeline entry:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
class Comment < ApplicationRecord
|
|
139
|
+
belongs_to :post
|
|
140
|
+
|
|
141
|
+
has_timeline :comment_changes,
|
|
142
|
+
class_name: 'ContentTimelineEntry',
|
|
143
|
+
meta: {
|
|
144
|
+
post_id: ->(record) { record.post_id },
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
If your timeline table has columns that match the keys in your `meta` hash, these values will be stored in
|
|
150
|
+
those dedicated columns. Otherwise they will be ignored. (This might change in the future and a metadata column might be
|
|
151
|
+
added to put additional metadata that doesn't have a dedicated column.)
|
|
152
|
+
|
|
153
|
+
#### Adding Metadata at Runtime
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# Add metadata for a specific operation
|
|
157
|
+
ModelTimeline.with_metadata(post_id: '123456') do
|
|
158
|
+
comment.update(body: 'Updated comment')
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Add metadata for the current thread/request
|
|
162
|
+
ModelTimeline.metadata = { post_id: '123456' }
|
|
163
|
+
comment.update(status: 'approved')
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
#### Custom Timeline Tables with Domain-specific Columns
|
|
167
|
+
|
|
168
|
+
For tracking related entities more effectively, you can create a custom timeline table with additional columns:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
# Migration to create a product-specific timeline table
|
|
172
|
+
class CreatePostTimelineEntries < ActiveRecord::Migration[6.1]
|
|
173
|
+
def change
|
|
174
|
+
create_table :post_timeline_entries do |t|
|
|
175
|
+
# Standard ModelTimeline columns
|
|
176
|
+
t.string :timelineable_type
|
|
177
|
+
t.integer :timelineable_id
|
|
178
|
+
t.string :action
|
|
179
|
+
t.jsonb :object_changes
|
|
180
|
+
t.integer :user_id
|
|
181
|
+
t.string :ip_address
|
|
182
|
+
|
|
183
|
+
# Custom columns that can be populated via the meta option
|
|
184
|
+
t.integer :post_id
|
|
185
|
+
|
|
186
|
+
t.timestamps
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
add_index :post_timeline_entries, [:timelineable_type, :timelineable_id]
|
|
190
|
+
add_index :post_timeline_entries, :post_id
|
|
191
|
+
add_index :post_timeline_entries, :user_id
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Then, use this table with your models:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class Comment < ApplicationRecord
|
|
200
|
+
belongs_to :post
|
|
201
|
+
|
|
202
|
+
has_timeline :product_changes,
|
|
203
|
+
class_name: 'PostTimelineEntry',
|
|
204
|
+
meta: {
|
|
205
|
+
post_id: ->(record) { record.post_id },
|
|
206
|
+
# OR
|
|
207
|
+
# post_id: :post_id
|
|
208
|
+
# OR
|
|
209
|
+
# post_id: :my_custom_post_id_method
|
|
210
|
+
}
|
|
211
|
+
end
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
With this approach, you can easily query all changes related to a specific post or product:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
# Find all timeline entries for a specific post
|
|
218
|
+
PostTimelineEntry.where(post_id: post.id)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
This makes it significantly easier to track and analyze changes across related models within a specific domain context.
|
|
222
|
+
|
|
223
|
+
### Controller Integration
|
|
224
|
+
|
|
225
|
+
Define the current user and ip_address for the current request
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
class ApplicationController < ActionController::Base
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
# ModelTimeline will look for the methods set in the initializer.
|
|
232
|
+
# Given
|
|
233
|
+
# ModelTimeline.configure do |config|
|
|
234
|
+
# config.current_user_method = :my_current_user
|
|
235
|
+
# config.current_ip_method = :remote_ip
|
|
236
|
+
# end
|
|
237
|
+
#
|
|
238
|
+
def my_current_user
|
|
239
|
+
my_current_user_instance
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def remote_ip
|
|
243
|
+
request.remote_ip
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Usage
|
|
249
|
+
|
|
250
|
+
### Basic Usage
|
|
251
|
+
|
|
252
|
+
Once configured, ModelTimeline automatically tracks changes to your models:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
user = User.create(username: 'johndoe', email: 'john@example.com')
|
|
256
|
+
# Creates a timeline entry with action: 'create'
|
|
257
|
+
|
|
258
|
+
user.update(email: 'new@example.com')
|
|
259
|
+
# Creates a timeline entry with action: 'update' and the changed attributes
|
|
260
|
+
|
|
261
|
+
user.destroy
|
|
262
|
+
# Creates a timeline entry with action: 'destroy'
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Accessing Timeline Entries
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
# Get all timeline entries for a model
|
|
270
|
+
user.timeline_entries
|
|
271
|
+
|
|
272
|
+
# Get timeline entries with a specific action
|
|
273
|
+
user.timeline_entries.where(action: 'update')
|
|
274
|
+
|
|
275
|
+
# Find entries for a specific user
|
|
276
|
+
ModelTimeline::TimelineEntry.for_user(admin)
|
|
277
|
+
|
|
278
|
+
# Find entries from a specific IP
|
|
279
|
+
ModelTimeline::TimelineEntry.for_ip_address('192.168.1.1')
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Custom Tables and Models
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
# Create a custom timeline entry class
|
|
286
|
+
class SecurityTimelineEntry < ModelTimeline::TimelineEntry
|
|
287
|
+
self.table_name = 'security_timeline_entries'
|
|
288
|
+
|
|
289
|
+
# Add custom scopes or methods
|
|
290
|
+
scope :critical, -> { where("object_changes::text ILIKE '%password%'") }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Use it in your model
|
|
294
|
+
class User < ApplicationRecord
|
|
295
|
+
has_timeline :security_timelines,
|
|
296
|
+
class_name: 'SecurityTimelineEntry',
|
|
297
|
+
only: [:sign_in_count, :last_sign_in_at, :role]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Access the custom timeline
|
|
301
|
+
user.security_timelines
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Controlling Timeline Recording
|
|
305
|
+
|
|
306
|
+
Temporarily enable or disable timeline recording:
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
# Disable timeline recording for a block of code
|
|
310
|
+
ModelTimeline.without_timeline do
|
|
311
|
+
# Changes made here won't be recorded
|
|
312
|
+
user.update(name: 'New Name')
|
|
313
|
+
post.destroy
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Set custom context for timeline entries
|
|
317
|
+
ModelTimeline.with_timeline(current_user: admin_user, current_ip: '10.0.0.1', metadata: { reason: 'Admin action' }) do
|
|
318
|
+
# Changes made here will be attributed to admin_user from 10.0.0.1
|
|
319
|
+
# with the additional metadata
|
|
320
|
+
user.update(status: 'suspended')
|
|
321
|
+
end
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Add additional contextual information to timeline entries:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
# Set metadata for all timeline entries in the current request
|
|
328
|
+
ModelTimeline.metadata = { import_batch: 'daily_sync_2023_01_01' }
|
|
329
|
+
|
|
330
|
+
# Temporarily add or override metadata for a block
|
|
331
|
+
ModelTimeline.with_metadata(source: 'api') do
|
|
332
|
+
# All timeline entries created here will include this metadata
|
|
333
|
+
user.update(status: 'active')
|
|
334
|
+
end
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Timeline Entry Scopes
|
|
338
|
+
|
|
339
|
+
ModelTimeline provides several useful scopes for querying timeline entries:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
# Find entries for a specific model
|
|
343
|
+
ModelTimeline::TimelineEntry.for_timelineable(user)
|
|
344
|
+
|
|
345
|
+
# Find entries created by a specific user
|
|
346
|
+
ModelTimeline::TimelineEntry.for_user(admin)
|
|
347
|
+
|
|
348
|
+
# Find entries from a specific IP address
|
|
349
|
+
ModelTimeline::TimelineEntry.for_ip_address('192.168.1.1')
|
|
350
|
+
|
|
351
|
+
# Find entries where a specific attribute was changed
|
|
352
|
+
ModelTimeline::TimelineEntry.with_changed_attribute('email')
|
|
353
|
+
|
|
354
|
+
# Find entries where an attribute was changed to a specific value
|
|
355
|
+
ModelTimeline::TimelineEntry.with_changed_value('status', 'active')
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### PostgreSQL-Specific Features
|
|
359
|
+
|
|
360
|
+
ModelTimeline leverages PostgreSQL's JSONB capabilities for efficient querying:
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
# Find timeline entries containing specific changes using JSONB containment
|
|
364
|
+
TimelineEntry.where("object_changes @> ?", {email: ["old@example.com", "new@example.com"]}.to_json)
|
|
365
|
+
|
|
366
|
+
# Search for any value in the changes
|
|
367
|
+
TimelineEntry.where("object_changes::text LIKE ?", "%specific_value%")
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The gem creates GIN indexes on the JSONB columns for optimized performance with large audit logs.
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
## RSpec Integration
|
|
374
|
+
|
|
375
|
+
### Configuration
|
|
376
|
+
|
|
377
|
+
Configure RSpec to work with ModelTimeline:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
# spec/support/model_timeline.rb
|
|
381
|
+
require 'model_timeline/rspec'
|
|
382
|
+
|
|
383
|
+
RSpec.configure do |config|
|
|
384
|
+
# This disables ModelTimeline by default in tests for better performance
|
|
385
|
+
config.before(:suite) do
|
|
386
|
+
ModelTimeline.disable!
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Include the RSpec helpers and matchers
|
|
390
|
+
config.include ModelTimeline::RSpec
|
|
391
|
+
end
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
#### Enabling Timeline in Tests
|
|
395
|
+
|
|
396
|
+
ModelTimeline is disabled by default in tests for performance. Enable it selectively:
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
# Enable timeline for a single test with metadata
|
|
400
|
+
it 'tracks changes', :with_timeline do
|
|
401
|
+
# ModelTimeline is enabled here
|
|
402
|
+
user = create(:user)
|
|
403
|
+
expect(user.timeline_entries).to exist
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Enable timeline for a group of tests
|
|
407
|
+
describe 'tracked actions', :with_timeline do
|
|
408
|
+
it 'tracks creation' do
|
|
409
|
+
post = create(:post)
|
|
410
|
+
expect(post.timeline_entries.count).to eq(1)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
it 'tracks updates' do
|
|
414
|
+
post = create(:post)
|
|
415
|
+
post.update(title: 'New Title')
|
|
416
|
+
expect(post.timeline_entries.count).to eq(2)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Tests without the metadata will have timeline disabled
|
|
421
|
+
it 'does not track changes' do
|
|
422
|
+
user = create(:user)
|
|
423
|
+
expect(ModelTimeline::TimelineEntry.count).to eq(0)
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
#### RSpec Matchers
|
|
428
|
+
|
|
429
|
+
ModelTimeline provides several matchers for testing timeline entries:
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
# Check for any timeline entries
|
|
433
|
+
expect(user).to have_timeline_entries
|
|
434
|
+
|
|
435
|
+
# Check for a specific number of entries
|
|
436
|
+
expect(user).to have_timeline_entries(3)
|
|
437
|
+
|
|
438
|
+
# Check for entries with a specific action
|
|
439
|
+
expect(user).to have_timelined_action(:update)
|
|
440
|
+
|
|
441
|
+
# Check if a specific attribute was changed
|
|
442
|
+
expect(user).to have_timelined_change(:email)
|
|
443
|
+
|
|
444
|
+
# Check if an attribute was changed to a specific value
|
|
445
|
+
expect(user).to have_timelined_entry(:status, 'active')
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
These matchers make it easy to test that your application is correctly tracking model changes.
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
## License
|
|
452
|
+
|
|
453
|
+
The gem is available as open source under the terms of the MIT License.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelTimeline
|
|
4
|
+
# Error raised when there's an issue with ModelTimeline configuration.
|
|
5
|
+
# This error is typically raised when attempting to define multiple timeline
|
|
6
|
+
# configurations for the same model and timeline entry class combination.
|
|
7
|
+
#
|
|
8
|
+
# @example Raising the error
|
|
9
|
+
# raise ModelTimeline::ConfigurationError.new
|
|
10
|
+
#
|
|
11
|
+
# @example With custom message
|
|
12
|
+
# raise ModelTimeline::ConfigurationError.new("Custom error message")
|
|
13
|
+
#
|
|
14
|
+
class ConfigurationError < StandardError
|
|
15
|
+
# Initialize a new ConfigurationError
|
|
16
|
+
#
|
|
17
|
+
# @param message [String] Custom error message
|
|
18
|
+
# @return [ModelTimeline::ConfigurationError] A new instance of ConfigurationError
|
|
19
|
+
def initialize(message = 'Multiple definitions of the same configuration found. ' \
|
|
20
|
+
'Please ensure that each configuration is unique.')
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelTimeline
|
|
4
|
+
# Provides controller functionality for automatically tracking model timeline information.
|
|
5
|
+
# When included in a controller, this module will capture the current user and IP address
|
|
6
|
+
# for each request and make them available for timeline entries.
|
|
7
|
+
#
|
|
8
|
+
# @example Adding to a specific controller
|
|
9
|
+
# class ApplicationController < ActionController::Base
|
|
10
|
+
# include ModelTimeline::ControllerAdditions
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# @example Using the class method
|
|
14
|
+
# class ApplicationController < ActionController::Base
|
|
15
|
+
# track_actions_with_model_timeline
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
module ControllerAdditions
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
included do
|
|
22
|
+
before_action :set_model_timeline_info
|
|
23
|
+
after_action :clear_model_timeline_info
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Sets the current user and IP address in the request store.
|
|
27
|
+
# Called automatically as a before_action.
|
|
28
|
+
#
|
|
29
|
+
# @return [void]
|
|
30
|
+
def set_model_timeline_info
|
|
31
|
+
user = (send(ModelTimeline.current_user_method) if respond_to?(ModelTimeline.current_user_method, true))
|
|
32
|
+
|
|
33
|
+
ip = begin
|
|
34
|
+
if request.respond_to?(ModelTimeline.current_ip_method)
|
|
35
|
+
request.send(ModelTimeline.current_ip_method)
|
|
36
|
+
else
|
|
37
|
+
request.remote_ip
|
|
38
|
+
end
|
|
39
|
+
rescue StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
ModelTimeline.store_user_and_ip(user, ip)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Clears the request store after the request is complete.
|
|
47
|
+
# Called automatically as an after_action.
|
|
48
|
+
#
|
|
49
|
+
# @return [void]
|
|
50
|
+
def clear_model_timeline_info
|
|
51
|
+
ModelTimeline.clear_request_store
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Class methods added to the including controller.
|
|
55
|
+
module ClassMethods
|
|
56
|
+
# Convenience method to include ModelTimeline::ControllerAdditions in a controller.
|
|
57
|
+
#
|
|
58
|
+
# @return [void]
|
|
59
|
+
def track_actions_with_model_timeline
|
|
60
|
+
include ModelTimeline::ControllerAdditions
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
|
|
6
|
+
module ModelTimeline
|
|
7
|
+
# Contains generators for setting up ModelTimeline in a Rails application.
|
|
8
|
+
# These generators help with creating necessary database tables and configurations.
|
|
9
|
+
module Generators
|
|
10
|
+
# Rails generator that creates the necessary migration file for ModelTimeline.
|
|
11
|
+
# his generator creates a migration to set up the timeline entries table.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# $ rails generate model_timeline:install
|
|
15
|
+
#
|
|
16
|
+
# @example With custom table name
|
|
17
|
+
# $ rails generate model_timeline:install --table_name=custom_timeline_entries
|
|
18
|
+
class InstallGenerator < Rails::Generators::Base
|
|
19
|
+
include Rails::Generators::Migration
|
|
20
|
+
|
|
21
|
+
source_root File.expand_path('templates', __dir__)
|
|
22
|
+
|
|
23
|
+
# @option options [String] :table_name ('model_timeline_timeline_entries')
|
|
24
|
+
# The name to use for the timeline entries database table
|
|
25
|
+
class_option :table_name, type: :string, desc: 'Name for the timeline entries table'
|
|
26
|
+
|
|
27
|
+
# Returns the next migration number to be used in the migration filename
|
|
28
|
+
#
|
|
29
|
+
# @param [String] dirname The directory where migrations are stored
|
|
30
|
+
# @return [String] The next migration number
|
|
31
|
+
def self.next_migration_number(dirname)
|
|
32
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Creates the migration file for ModelTimeline tables
|
|
36
|
+
#
|
|
37
|
+
# @return [void]
|
|
38
|
+
def create_migration_file
|
|
39
|
+
@table_name = options[:table_name] || 'model_timeline_timeline_entries'
|
|
40
|
+
migration_template 'migration.rb.tt', 'db/migrate/create_model_timeline_tables.rb'
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class CreateModelTimelineTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :<%= @table_name %> do |t|
|
|
4
|
+
t.string :timelineable_type
|
|
5
|
+
t.bigint :timelineable_id
|
|
6
|
+
t.string :action, null: false
|
|
7
|
+
|
|
8
|
+
# Use PostgreSQL's JSONB type for better performance
|
|
9
|
+
t.jsonb :object_changes, default: {}, null: false
|
|
10
|
+
|
|
11
|
+
# Polymorphic user association
|
|
12
|
+
t.string :user_type
|
|
13
|
+
t.bigint :user_id
|
|
14
|
+
t.string :username
|
|
15
|
+
|
|
16
|
+
# IP address tracking
|
|
17
|
+
t.inet :ip_address
|
|
18
|
+
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
add_index :<%= @table_name %>, [:timelineable_type, :timelineable_id], name: 'idx_timeline_on_timelineable'
|
|
23
|
+
add_index :<%= @table_name %>, [:user_type, :user_id], name: 'idx_timeline_on_user'
|
|
24
|
+
add_index :<%= @table_name %>, :object_changes, using: :gin, name: 'idx_timeline_on_changes'
|
|
25
|
+
add_index :<%= @table_name %>, :ip_address, name: 'idx_timeline_on_ip'
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelTimeline
|
|
4
|
+
# Rails integration for ModelTimeline.
|
|
5
|
+
# This Railtie automatically integrates ModelTimeline with Rails by:
|
|
6
|
+
# - Including the Timelineable module in all ActiveRecord models
|
|
7
|
+
# - Making controller helper methods available in all ActionControllers
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # This class is automatically loaded by Rails, no manual inclusion required
|
|
11
|
+
# # Rails.application.initialize!
|
|
12
|
+
#
|
|
13
|
+
class Railtie < Rails::Railtie
|
|
14
|
+
# @!method initializer(name, &block)
|
|
15
|
+
# Initializes ModelTimeline by including necessary modules in Rails components.
|
|
16
|
+
# Called automatically when Rails loads.
|
|
17
|
+
#
|
|
18
|
+
# @param name [String] The name of the initializer
|
|
19
|
+
# @param block [Proc] The initialization code to run
|
|
20
|
+
# @return [void]
|
|
21
|
+
initializer 'model_timeline.initialize' do
|
|
22
|
+
ActiveSupport.on_load(:active_record) do
|
|
23
|
+
include Timelineable
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
ActiveSupport.on_load(:action_controller) do
|
|
27
|
+
include ControllerAdditions::ClassMethods
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|