paper_trail-related_changes 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/Rakefile +22 -0
- data/app/assets/config/paper_trail_changelog_manifest.js +1 -0
- data/app/assets/stylesheets/paper_trail/changelog/application.css +15 -0
- data/app/controllers/paper_trail/related_changes/application_controller.rb +9 -0
- data/app/controllers/paper_trail/related_changes/base_controller.rb +25 -0
- data/app/helpers/paper_trail/changelog/application_helper.rb +6 -0
- data/app/jobs/paper_trail/changelog/application_job.rb +6 -0
- data/app/mailers/paper_trail/changelog/application_mailer.rb +8 -0
- data/app/models/paper_trail/related_changes/application_record.rb +7 -0
- data/app/views/layouts/paper_trail/related_changes/application.html.erb +15 -0
- data/config/routes.rb +3 -0
- data/lib/paper_trail/related_changes.rb +35 -0
- data/lib/paper_trail/related_changes/attribute.rb +11 -0
- data/lib/paper_trail/related_changes/build_changes.rb +83 -0
- data/lib/paper_trail/related_changes/change.rb +40 -0
- data/lib/paper_trail/related_changes/engine.rb +15 -0
- data/lib/paper_trail/related_changes/grouped_by_request_id.rb +154 -0
- data/lib/paper_trail/related_changes/hierarchy.rb +117 -0
- data/lib/paper_trail/related_changes/hierarchy/query.rb +78 -0
- data/lib/paper_trail/related_changes/relationally_independent.rb +14 -0
- data/lib/paper_trail/related_changes/serializer.rb +117 -0
- data/lib/paper_trail/related_changes/serializer/belongs_to.rb +73 -0
- data/lib/paper_trail/related_changes/serializer/diff.rb +23 -0
- data/lib/paper_trail/related_changes/serializer/polymorphic.rb +19 -0
- data/lib/paper_trail/related_changes/serializer/skippable.rb +17 -0
- data/lib/paper_trail/related_changes/version.rb +5 -0
- data/lib/paper_trail/related_changes/version_model.rb +35 -0
- data/lib/tasks/paper_trail/changelog_tasks.rake +4 -0
- metadata +130 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 9765e2d2318f809d303c5d36168083ce3abcd9abd0470862c39e1778ff546a5a
|
4
|
+
data.tar.gz: 70b70b6b660f2cc1eb54545ee994325b4e22c5379f03389a94aa80ba44dc0ea3
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: bcdf9b171bfe8a25009f6f4f0bcfb7646aae39370410fbf13bdd3bd4a8646f3d6203ae999d3990158fd79a719cb63562a183a62a138337981033b6b493b95b62
|
7
|
+
data.tar.gz: 2e9e3e5839c709b8ef7dca6ebc3df17b1238339abcd65906d96600410191a6ecc4a2a727b6c6eafa627f4534d6f235c8f1c82fc2c56c01467eb9aabbc813f06f
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2020 Dustin Zeisler
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# PaperTrail::RelatedChanges
|
2
|
+
Short description and motivation.
|
3
|
+
|
4
|
+
## Usage
|
5
|
+
How to use my plugin.
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
Add this line to your application's Gemfile:
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
gem 'paper_trail-related_changes'
|
12
|
+
```
|
13
|
+
|
14
|
+
And then execute:
|
15
|
+
```bash
|
16
|
+
$ bundle
|
17
|
+
```
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
```bash
|
21
|
+
$ gem install paper_trail-related_changes
|
22
|
+
```
|
23
|
+
|
24
|
+
## Contributing
|
25
|
+
Contribution directions go here.
|
26
|
+
|
27
|
+
## License
|
28
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'rdoc/task'
|
8
|
+
|
9
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
10
|
+
rdoc.rdoc_dir = 'rdoc'
|
11
|
+
rdoc.title = 'PaperTrail::RelatedChanges'
|
12
|
+
rdoc.options << '--line-numbers'
|
13
|
+
rdoc.rdoc_files.include('README.md')
|
14
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
15
|
+
end
|
16
|
+
|
17
|
+
APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
|
18
|
+
load 'rails/tasks/engine.rake'
|
19
|
+
|
20
|
+
load 'rails/tasks/statistics.rake'
|
21
|
+
|
22
|
+
require 'bundler/gem_tasks'
|
@@ -0,0 +1 @@
|
|
1
|
+
//= link_directory ../stylesheets/paper_trail/related_changes .css
|
@@ -0,0 +1,15 @@
|
|
1
|
+
/*
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
3
|
+
* listed below.
|
4
|
+
*
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
7
|
+
*
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
11
|
+
* It is generally better to create a new file per style scope.
|
12
|
+
*
|
13
|
+
*= require_tree .
|
14
|
+
*= require_self
|
15
|
+
*/
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module PaperTrail
|
2
|
+
module RelatedChanges
|
3
|
+
class BaseController < PaperTrail::RelatedChanges::ApplicationController
|
4
|
+
def show
|
5
|
+
render json: { data: versions.to_a, meta: {} }
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def versions
|
11
|
+
RelatedChanges::GroupedByRequestId.new(
|
12
|
+
limit: limit,
|
13
|
+
**params.permit!.to_h.symbolize_keys.slice(
|
14
|
+
:type,
|
15
|
+
:id,
|
16
|
+
)
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
def limit
|
21
|
+
params[:limit] || params.dig('page', 'size')
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>Paper trail related_changes</title>
|
5
|
+
<%= csrf_meta_tags %>
|
6
|
+
<%= csp_meta_tag %>
|
7
|
+
|
8
|
+
<%= stylesheet_link_tag "paper_trail/related_changes/application", media: "all" %>
|
9
|
+
</head>
|
10
|
+
<body>
|
11
|
+
|
12
|
+
<%= yield %>
|
13
|
+
|
14
|
+
</body>
|
15
|
+
</html>
|
data/config/routes.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'paper_trail/frameworks/active_record/models/paper_trail/version'
|
2
|
+
require 'paper_trail/related_changes/relationally_independent'
|
3
|
+
require 'paper_trail/related_changes/version_model'
|
4
|
+
require "paper_trail/related_changes/engine"
|
5
|
+
require "paper_trail/related_changes/serializer"
|
6
|
+
require "paper_trail/related_changes/grouped_by_request_id"
|
7
|
+
require "paper_trail/related_changes/hierarchy"
|
8
|
+
require "paper_trail/related_changes/build_changes"
|
9
|
+
require "paper_trail/related_changes/version"
|
10
|
+
|
11
|
+
module PaperTrail
|
12
|
+
module RelatedChanges
|
13
|
+
def self.serializers
|
14
|
+
@serializers ||= [
|
15
|
+
Serializer::Skippable,
|
16
|
+
Serializer::BelongsTo,
|
17
|
+
Serializer::Polymorphic
|
18
|
+
]
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.insert_after_serializer(serializer, after_serializer)
|
22
|
+
serializer_index = serializers.index(serializer)
|
23
|
+
@serializers = serializers.insert(serializer_index + 1, after_serializer)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.insert_before_serializer(serializer, after_serializer)
|
27
|
+
serializer_index = serializers.index(serializer)
|
28
|
+
@serializers = serializers.insert(serializer_index, after_serializer)
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.user_class
|
32
|
+
User if defined? User
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module PaperTrail
|
2
|
+
module RelatedChanges
|
3
|
+
class BuildChanges
|
4
|
+
attr_reader :results,
|
5
|
+
:model_type_children,
|
6
|
+
:item_type,
|
7
|
+
:item_id
|
8
|
+
|
9
|
+
def initialize(results, model_type_children, item_type, item_id)
|
10
|
+
@results = results
|
11
|
+
@model_type_children = model_type_children
|
12
|
+
@item_type = item_type
|
13
|
+
@item_id = item_id
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
changes = results.map do |root_version, versions, request_id|
|
18
|
+
versions_serialized = versions_serialized(versions)
|
19
|
+
root_change = change_with_root(request_id, root_version, versions_serialized)
|
20
|
+
root_change.change.children = children_versions(versions_serialized).map(&:change)
|
21
|
+
root_change.change
|
22
|
+
end.compact
|
23
|
+
|
24
|
+
remove_duplicate_changes(changes)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# Given a case where a change is represented by a previous change do not show it.
|
30
|
+
def remove_duplicate_changes(changes)
|
31
|
+
changes.reject.with_index do |change, index|
|
32
|
+
stop_iteration = false
|
33
|
+
changes[(index + 1)..-1].each do |previous_change|
|
34
|
+
change.diffs = change.diffs.reject do |current_change|
|
35
|
+
next false if stop_iteration
|
36
|
+
previous_matched_change = previous_change
|
37
|
+
.diffs
|
38
|
+
.detect { |previous_change| previous_change.attribute == current_change.attribute }
|
39
|
+
stop_iteration = true if previous_matched_change # As soon as their is match stop
|
40
|
+
previous_matched_change.eql?(current_change)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
change.empty?
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def change_with_root(request_id, root_version, versions_serialized)
|
49
|
+
s = Serializer.new(root_version, item_type: root_version.item_type, root_type: item_type)
|
50
|
+
s.change.diffs = [*s.change.diffs, *attribute_version_changes(versions_serialized)]
|
51
|
+
s.change.version_id = request_id || root_version.request_id
|
52
|
+
s
|
53
|
+
end
|
54
|
+
|
55
|
+
def children_versions(versions_serialized)
|
56
|
+
versions_serialized.reject { |vr| vr.change.merge_into_root }
|
57
|
+
end
|
58
|
+
|
59
|
+
def attribute_version_changes(versions_serialized)
|
60
|
+
versions_serialized.select { |vr| vr.change.merge_into_root }.map(&:change).flat_map(&:diffs)
|
61
|
+
end
|
62
|
+
|
63
|
+
def versions_serialized(versions)
|
64
|
+
versions.flat_map do |parent_type, versions_with_child_type|
|
65
|
+
versions_with_child_type.flat_map do |_type, versions_of_child|
|
66
|
+
versions_of_child.map do |version|
|
67
|
+
Serializer.new(
|
68
|
+
version,
|
69
|
+
item_type: parent_type,
|
70
|
+
model_to_include_name: model_to_include_name,
|
71
|
+
root_type: item_type
|
72
|
+
)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def model_to_include_name
|
79
|
+
model_type_children.each_with_object({}) { |(n, r), h| h[r.klass.name] = n }
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module PaperTrail::RelatedChanges
|
2
|
+
Change = Struct.new(:version_id,
|
3
|
+
:user,
|
4
|
+
:event,
|
5
|
+
:resource,
|
6
|
+
:description,
|
7
|
+
:resource_id,
|
8
|
+
:diffs,
|
9
|
+
:timestamp,
|
10
|
+
:requested_root,
|
11
|
+
:children,
|
12
|
+
:merge_into_root,
|
13
|
+
keyword_init: true) do
|
14
|
+
def initialize(diffs: [], merge_into_root: false, **args)
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_h(*)
|
19
|
+
self.diffs = diffs
|
20
|
+
.group_by(&:source)
|
21
|
+
.map { |k, g| [k, g.sort_by(&:source_rank)] } # ie. segments can be in display order
|
22
|
+
.sort_by { |_, g| g[0].rank } # Direct attributes shown first
|
23
|
+
.flat_map(&:last)
|
24
|
+
.uniq
|
25
|
+
results = super().except(:merge_into_root).map { |k, v| [k, v.is_a?(Array) ? v.map(&:to_h) : v] }.to_h
|
26
|
+
results.delete(:children) if results[:children].nil?
|
27
|
+
results
|
28
|
+
end
|
29
|
+
|
30
|
+
alias_method :as_json, :to_h
|
31
|
+
|
32
|
+
def empty?
|
33
|
+
diffs.count.zero? && children.count.zero?
|
34
|
+
end
|
35
|
+
|
36
|
+
def add_diff(args)
|
37
|
+
self.diffs << PaperTrail::RelatedChanges::Serializer::Diff.new(args)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module PaperTrail
|
2
|
+
module RelatedChanges
|
3
|
+
class Engine < ::Rails::Engine
|
4
|
+
isolate_namespace PaperTrail::RelatedChanges
|
5
|
+
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
7
|
+
extend PaperTrail::RelatedChanges::RelationallyIndependent
|
8
|
+
end
|
9
|
+
|
10
|
+
config.after_initialize do
|
11
|
+
PaperTrail::Version.include(PaperTrail::VersionModel)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
module PaperTrail
|
2
|
+
module RelatedChanges
|
3
|
+
# Goal of class is to group versions rows into groups that represent a user event.
|
4
|
+
# When a user saves a resource that may have many associated resources and we want to see that as one event.
|
5
|
+
# Use ActiveRecord#reflections build a tree of downward relationships and query the versions.object and version.object_changes
|
6
|
+
# Group by the request_id to collect all actions into an event.
|
7
|
+
class GroupedByRequestId
|
8
|
+
attr_reader :item_id,
|
9
|
+
:item_type,
|
10
|
+
:limit
|
11
|
+
|
12
|
+
def initialize(item_type: nil,
|
13
|
+
type: nil,
|
14
|
+
item_id: nil,
|
15
|
+
id: nil,
|
16
|
+
limit: nil)
|
17
|
+
@item_type = (item_type || type).underscore.classify
|
18
|
+
@item_id = item_id || id
|
19
|
+
@limit = Integer([limit, 1_000].reject(&:blank?).first)
|
20
|
+
@append_root = true
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_a
|
24
|
+
BuildChanges.new(
|
25
|
+
raw_records,
|
26
|
+
hierarchy.model_type_children,
|
27
|
+
model_name,
|
28
|
+
item_id
|
29
|
+
).call.take(limit) # appending the last root version can cause limit+1 sized results.
|
30
|
+
end
|
31
|
+
|
32
|
+
def raw_records
|
33
|
+
results.map do |result|
|
34
|
+
root_version, versions = build_versions(result)
|
35
|
+
[root_version, group_versions(versions), result["request_id"]]
|
36
|
+
end.concat(append_root_version)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
# When a limit is used you may not have a root record. A root record will collapse many versions into it's self.
|
42
|
+
# Without a root record you will see different types of records that won't be seen when no limit is set.
|
43
|
+
def append_root_version
|
44
|
+
return [] unless @append_root
|
45
|
+
[[last_root, []]]
|
46
|
+
end
|
47
|
+
|
48
|
+
def results
|
49
|
+
conn = ActiveRecord::Base.connection
|
50
|
+
conn.execute(<<~SQL)
|
51
|
+
SELECT json_agg(hierarchy_versions ORDER BY (rank, item_type, item_id, id)) AS versions,
|
52
|
+
MIN(hierarchy_versions.created_at) as created_at,
|
53
|
+
request_id
|
54
|
+
FROM (#{hierarchy.find_by_id(item_id)}) AS hierarchy_versions
|
55
|
+
GROUP BY request_id
|
56
|
+
ORDER BY created_at DESC
|
57
|
+
LIMIT #{conn.quote(limit)}
|
58
|
+
SQL
|
59
|
+
end
|
60
|
+
|
61
|
+
def last_root
|
62
|
+
@last_root ||= PaperTrail::Version.where(
|
63
|
+
item_type: item_type,
|
64
|
+
item_id: item_id
|
65
|
+
).order(
|
66
|
+
created_at: :desc
|
67
|
+
).limit(1).first
|
68
|
+
end
|
69
|
+
|
70
|
+
def build_versions(result)
|
71
|
+
requested_root_version = nil
|
72
|
+
versions = JSON.parse(result["versions"]).map do |version|
|
73
|
+
record = convert_to_record(version)
|
74
|
+
requested_root_version = record if record.item_id == item_id.to_s && record.item_type == model_name
|
75
|
+
record
|
76
|
+
end
|
77
|
+
@append_root = false if requested_root_version == last_root
|
78
|
+
root_version = requested_root_version || find_root(versions, result["request_id"])
|
79
|
+
versions.delete(root_version)
|
80
|
+
[root_version, versions]
|
81
|
+
end
|
82
|
+
|
83
|
+
def find_root(versions, request_id)
|
84
|
+
return versions.first if versions.count == 1 && versions.first.model_class.relationally_independent?
|
85
|
+
shared_relation = hierarchy.shared_relation(versions)
|
86
|
+
root_version = versions.detect { |version| version.item_type == shared_relation.dig(:relation).class_name }
|
87
|
+
|
88
|
+
return build_sudo_root(shared_relation, request_id, versions) unless root_version
|
89
|
+
root_version
|
90
|
+
end
|
91
|
+
|
92
|
+
def merge_event(versions)
|
93
|
+
if versions.map(&:event).uniq.count == 1
|
94
|
+
versions.map(&:event).uniq.first
|
95
|
+
else
|
96
|
+
'update'
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def convert_to_record(version)
|
101
|
+
PaperTrail::Version.new(
|
102
|
+
**version.except('created_at', 'rank').symbolize_keys,
|
103
|
+
created_at: ActiveSupport::TimeZone["UTC"].parse(version['created_at'])
|
104
|
+
)
|
105
|
+
end
|
106
|
+
|
107
|
+
def group_versions(versions)
|
108
|
+
versions.each_with_object({}) do |version, hash|
|
109
|
+
sources = hierarchy.search_hierarchy(version.item_type)
|
110
|
+
|
111
|
+
next unless (relation_to_root = sources.min_by { |s| s[:name].length }) # Prefer the shortest name ie. note vs notes
|
112
|
+
hash[relation_to_root[:parent][:type]] ||= {}
|
113
|
+
hash[relation_to_root[:parent][:type]][relation_to_root[:name]] ||= []
|
114
|
+
hash[relation_to_root[:parent][:type]][relation_to_root[:name]] << version
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def build_sudo_root(shared_relation, request_id, versions)
|
119
|
+
# Assigning the parent_id will allow the description.name to be populated.
|
120
|
+
extracted_reference_keys = versions.map { |version| hierarchy.search_hierarchy(version.item_type)&.first&.fetch(:relation) }.map { |v| [v.foreign_key, v.type].compact }
|
121
|
+
parent_id = versions.flat_map { |version| extracted_reference_keys.map { |keys| version.extract(*keys) } }.flatten.select { |t| t.class == Integer }.first
|
122
|
+
|
123
|
+
PaperTrail::Version.new(
|
124
|
+
item_type: shared_relation.dig(:relation).class_name,
|
125
|
+
item_id: parent_id,
|
126
|
+
request_id: request_id,
|
127
|
+
event: merge_event(versions),
|
128
|
+
created_at: versions.first.created_at,
|
129
|
+
whodunnit: versions.first.whodunnit
|
130
|
+
)
|
131
|
+
end
|
132
|
+
|
133
|
+
# This might happen if a developer did a mass edit in the console of unrelated items.
|
134
|
+
# If there are competing versions that match the request type only include the one with the matching id
|
135
|
+
def remove_stowaways(requested_root_version, versions)
|
136
|
+
versions.reject do |version|
|
137
|
+
version.item_type == requested_root_version.item_type && version.item_id != requested_root_version.item_id
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def model_class
|
142
|
+
model_name.constantize
|
143
|
+
end
|
144
|
+
|
145
|
+
def model_name
|
146
|
+
item_type.underscore.classify
|
147
|
+
end
|
148
|
+
|
149
|
+
def hierarchy
|
150
|
+
@hierarchy ||= PaperTrail::RelatedChanges::Hierarchy.new(model_class)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
module PaperTrail
|
2
|
+
module RelatedChanges
|
3
|
+
class Hierarchy
|
4
|
+
require "paper_trail/related_changes/hierarchy/query"
|
5
|
+
|
6
|
+
class << self
|
7
|
+
# Builds downward relational hierarchy with 4 generations
|
8
|
+
def build(*args)
|
9
|
+
self.new(*args).send(:build_source)
|
10
|
+
end
|
11
|
+
|
12
|
+
# Does filtering of relations
|
13
|
+
def model_type_children(model)
|
14
|
+
self.new(model).model_type_children
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
attr_reader :model
|
19
|
+
|
20
|
+
def initialize(model)
|
21
|
+
@model = model
|
22
|
+
end
|
23
|
+
|
24
|
+
def find_by_id(id)
|
25
|
+
Query.call(model, id)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Find the node that matches the item_type
|
29
|
+
# The result has a parent reference instead of a child reference.
|
30
|
+
def search_hierarchy(item_type, source: send(:source), parent: nil)
|
31
|
+
parent_without_children = (parent || source).except(:children)
|
32
|
+
return [source.except(:children).merge(parent: parent_without_children)] if source[:type] == item_type
|
33
|
+
return if source[:children].empty?
|
34
|
+
source[:children].flat_map { |child| search_hierarchy(item_type, source: child, parent: source) }.compact
|
35
|
+
end
|
36
|
+
|
37
|
+
# Finds a common root between versions
|
38
|
+
def shared_relation(versions)
|
39
|
+
result = versions.map do |version|
|
40
|
+
[
|
41
|
+
version.item_type, # It can be it's self
|
42
|
+
*search_hierarchy(version.item_type).map { |s| s.fetch(:parent, {}).fetch(:type, s[:type]) }
|
43
|
+
].uniq
|
44
|
+
end.inject(:&)&.last
|
45
|
+
|
46
|
+
search_hierarchy(result)&.first
|
47
|
+
end
|
48
|
+
|
49
|
+
def model_type_children
|
50
|
+
@model_type_children ||= model.reflections.each_with_object({}) do |(name, rel), result|
|
51
|
+
next if source_reflection(rel).belongs_to? # Only Looking for downward relations. (Children not parents)
|
52
|
+
next if name == PaperTrail::Version.table_name
|
53
|
+
result[name.to_sym] = source_reflection(rel)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def source
|
58
|
+
@source ||= build_source
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def build_source
|
64
|
+
top_relation = SelfRelation.new(foreign_key: :id, class_name: model.name, name: model.name)
|
65
|
+
root = Node.new(type: model.name, name: model.name, relation: top_relation, children: [])
|
66
|
+
model_type_children.each do |n1, r1|
|
67
|
+
next if r1.klass == model
|
68
|
+
|
69
|
+
r1_branch = Node.new(type: r1.klass.name, name: n1, relation: r1, children: [])
|
70
|
+
root.children << r1_branch
|
71
|
+
|
72
|
+
self.class.model_type_children(r1.klass).each do |n2, r2|
|
73
|
+
next if r2.klass == model
|
74
|
+
|
75
|
+
r2_branch = Node.new(type: r2.klass.name, name: n2, relation: r2, children: [])
|
76
|
+
r1_branch.children << r2_branch
|
77
|
+
|
78
|
+
self.class.model_type_children(r2.klass).each do |n3, r3|
|
79
|
+
next if r3.klass == model
|
80
|
+
|
81
|
+
r3_branch = Node.new(type: r3.klass.name, name: n3, relation: r3, children: [])
|
82
|
+
r2_branch.children << r3_branch
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
root
|
87
|
+
end
|
88
|
+
|
89
|
+
def source_reflection(v)
|
90
|
+
v.through_reflection? ? source_reflection(v.through_reflection) : v
|
91
|
+
end
|
92
|
+
|
93
|
+
def include_parent?(name)
|
94
|
+
include_parent_as_child.include?(name.to_sym)
|
95
|
+
end
|
96
|
+
|
97
|
+
def whitelisted_child?(name)
|
98
|
+
return true unless only_include_children
|
99
|
+
|
100
|
+
only_include_children.include?(name.to_sym)
|
101
|
+
end
|
102
|
+
|
103
|
+
SelfRelation = Struct.new(:foreign_key, :class_name, :name, keyword_init: true)
|
104
|
+
Node = Struct.new(:type, :name, :relation, :children, keyword_init: true) do
|
105
|
+
def to_simple
|
106
|
+
base = to_h.except(:relation, :children)
|
107
|
+
return { **base, children: children.map(&:to_simple) } unless children.empty?
|
108
|
+
base
|
109
|
+
end
|
110
|
+
|
111
|
+
def except(*args)
|
112
|
+
to_h.except(*args)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
module PaperTrail::RelatedChanges::Hierarchy::Query
|
2
|
+
class << self
|
3
|
+
# Builds a UNION joined set of queries that finds related versions to the 4th generation.
|
4
|
+
def call(model, id)
|
5
|
+
parent_query_r0 = parent(model.name, id)
|
6
|
+
hierarchy = PaperTrail::RelatedChanges::Hierarchy.model_type_children(model).each_with_object([]) do |(_n, r1), col|
|
7
|
+
parent_query_r1 = call_query(parent_query_r0, r1)
|
8
|
+
col << parent_query_r1
|
9
|
+
PaperTrail::RelatedChanges::Hierarchy.model_type_children(r1.klass).each do |_n, r2|
|
10
|
+
parent_query_r2 = call_query(parent_query_r1, r2)
|
11
|
+
col << parent_query_r2
|
12
|
+
PaperTrail::RelatedChanges::Hierarchy.model_type_children(r2.klass).each do |_n, r3|
|
13
|
+
parent_query_r3 = call_query(parent_query_r2, r3)
|
14
|
+
col << parent_query_r3
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
final = [
|
19
|
+
parent_query_r0,
|
20
|
+
*hierarchy.uniq
|
21
|
+
].join("\nUNION\n")
|
22
|
+
|
23
|
+
<<~SQL
|
24
|
+
SELECT #{columns('hierarchy')},
|
25
|
+
CASE event
|
26
|
+
WHEN 'create' THEN 1
|
27
|
+
WHEN 'update' THEN 2
|
28
|
+
WHEN 'destroy' THEN 3
|
29
|
+
ELSE 4 END as rank
|
30
|
+
FROM (#{final}) hierarchy
|
31
|
+
SQL
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def call_query(parent_query, r)
|
37
|
+
if r.type
|
38
|
+
nth_polymorphic_children(parent_query, r.type, r.foreign_key)
|
39
|
+
else
|
40
|
+
nth_children(parent_query, r.foreign_key)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def parent(item_type, item_id)
|
45
|
+
<<~SQL
|
46
|
+
SELECT '#{item_type}=>#{item_id}' as tree, #{columns('versions')}
|
47
|
+
FROM versions
|
48
|
+
WHERE versions.item_type = '#{item_type}'
|
49
|
+
AND versions.item_id = '#{item_id}'
|
50
|
+
SQL
|
51
|
+
end
|
52
|
+
|
53
|
+
def nth_children(parent_query, parent_foreign_key)
|
54
|
+
<<~SQL
|
55
|
+
SELECT parents.tree || '.' || children.item_type || '=>' || children.item_id as tree, #{columns('children')}
|
56
|
+
FROM (#{parent_query}) parents
|
57
|
+
JOIN versions children ON parents.item_id = #{query_changes(parent_foreign_key)}
|
58
|
+
SQL
|
59
|
+
end
|
60
|
+
|
61
|
+
def nth_polymorphic_children(parent_query, parent_type_foreign_key, parent_id_foreign_key)
|
62
|
+
<<~SQL
|
63
|
+
SELECT parents.tree || '.' || children.item_type || '=>' || children.item_id as tree, #{columns('children')}
|
64
|
+
FROM (#{parent_query}) parents
|
65
|
+
JOIN versions children ON parents.item_id = #{query_changes(parent_id_foreign_key)}
|
66
|
+
AND parents.item_type = #{query_changes(parent_type_foreign_key)}
|
67
|
+
SQL
|
68
|
+
end
|
69
|
+
|
70
|
+
def query_changes(key)
|
71
|
+
"COALESCE((children.object_changes -> '#{key}' ->> 1)::TEXT, (children.object ->> '#{key}')::TEXT)"
|
72
|
+
end
|
73
|
+
|
74
|
+
def columns(prefix)
|
75
|
+
%w(id item_type item_id event whodunnit object object_changes request_id created_at).map { |c| "#{prefix}.#{c}" }.join(", ")
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module PaperTrail::RelatedChanges::RelationallyIndependent
|
2
|
+
def relationally_independent=(value)
|
3
|
+
@relationally_independent = value
|
4
|
+
end
|
5
|
+
|
6
|
+
def relationally_independent?
|
7
|
+
if instance_variable_defined? :@relationally_independent
|
8
|
+
@relationally_independent
|
9
|
+
else
|
10
|
+
true
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
@@ -0,0 +1,117 @@
|
|
1
|
+
require "paper_trail/related_changes/serializer/polymorphic"
|
2
|
+
require "paper_trail/related_changes/serializer/belongs_to"
|
3
|
+
require "paper_trail/related_changes/serializer/skippable"
|
4
|
+
require "paper_trail/related_changes/attribute"
|
5
|
+
require "paper_trail/related_changes/serializer/diff"
|
6
|
+
require "paper_trail/related_changes/change"
|
7
|
+
|
8
|
+
module PaperTrail
|
9
|
+
module RelatedChanges
|
10
|
+
class Serializer
|
11
|
+
def initialize(record, item_type:, model_to_include_name: {}, root_type: nil)
|
12
|
+
@record = record
|
13
|
+
@item_type = item_type
|
14
|
+
@model_to_include_name = model_to_include_name
|
15
|
+
@root_type = root_type
|
16
|
+
end
|
17
|
+
|
18
|
+
delegate :to_h, to: :change
|
19
|
+
|
20
|
+
def change
|
21
|
+
@change ||= change_template
|
22
|
+
build_changes
|
23
|
+
@change
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def change_template
|
29
|
+
Change.new(
|
30
|
+
version_id: record.id,
|
31
|
+
user: user,
|
32
|
+
event: record.event,
|
33
|
+
resource: record.item_type,
|
34
|
+
description: {
|
35
|
+
name: resource_title,
|
36
|
+
value: record.name
|
37
|
+
},
|
38
|
+
resource_id: record.item_id,
|
39
|
+
timestamp: record.created_at,
|
40
|
+
requested_root: !included?
|
41
|
+
)
|
42
|
+
end
|
43
|
+
|
44
|
+
attr_reader :record,
|
45
|
+
:item_type,
|
46
|
+
:model_to_include_name,
|
47
|
+
:root_type
|
48
|
+
|
49
|
+
def included?
|
50
|
+
return record.item_type != root_type unless root_type.nil?
|
51
|
+
record.item_type != item_type.classify
|
52
|
+
end
|
53
|
+
|
54
|
+
def resource_title
|
55
|
+
model_to_include_name.fetch(
|
56
|
+
record.item_type,
|
57
|
+
record.item_type.underscore.split('/').last
|
58
|
+
).to_s.singularize.titleize
|
59
|
+
end
|
60
|
+
|
61
|
+
def user
|
62
|
+
PaperTrail::RelatedChanges.user_class.find_by(id: record.whodunnit)&.name || "system"
|
63
|
+
end
|
64
|
+
|
65
|
+
def build_changes
|
66
|
+
@build_changes ||= record.changeset.each do |attr, diff|
|
67
|
+
BuildDiffs.new(attr, diff, record, item_type, @change).call
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
class BuildDiffs
|
72
|
+
def initialize(attr, diff, record, request_type, change)
|
73
|
+
@attr = attr.to_sym
|
74
|
+
@diff = diff
|
75
|
+
@record = record
|
76
|
+
@request_type = request_type
|
77
|
+
@change = change
|
78
|
+
end
|
79
|
+
|
80
|
+
def call
|
81
|
+
return call_serializer if custom_serializer
|
82
|
+
|
83
|
+
change.diffs << default_diff
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
attr_reader :diff,
|
89
|
+
:attr,
|
90
|
+
:record,
|
91
|
+
:request_type,
|
92
|
+
:change
|
93
|
+
|
94
|
+
def default_diff
|
95
|
+
Diff.new(attribute: attr, old: diff[0], new: diff[1], rank: 0, meta: record, source: :default)
|
96
|
+
end
|
97
|
+
|
98
|
+
def attribute
|
99
|
+
@attribute ||= Attribute.new(
|
100
|
+
name: attr,
|
101
|
+
diff: diff,
|
102
|
+
version: record,
|
103
|
+
request_type: request_type
|
104
|
+
)
|
105
|
+
end
|
106
|
+
|
107
|
+
def custom_serializer
|
108
|
+
RelatedChanges.serializers.detect { |serializer| serializer.match(attribute) }
|
109
|
+
end
|
110
|
+
|
111
|
+
def call_serializer
|
112
|
+
custom_serializer.serialize(attribute, change)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
class PaperTrail::RelatedChanges::Serializer
|
2
|
+
class BelongsTo
|
3
|
+
|
4
|
+
def self.match(attribute)
|
5
|
+
attribute.version
|
6
|
+
.model_class
|
7
|
+
.reflections
|
8
|
+
.values
|
9
|
+
.select(&:belongs_to?)
|
10
|
+
.reject(&:polymorphic?)
|
11
|
+
.any? { |r| r.join_foreign_key.to_sym == attribute.to_sym }
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.serialize(attribute, change)
|
15
|
+
new(attribute, change).serialize
|
16
|
+
end
|
17
|
+
|
18
|
+
attr_reader :attribute,
|
19
|
+
:item_type,
|
20
|
+
:change
|
21
|
+
|
22
|
+
def initialize(attribute, change)
|
23
|
+
@attribute = attribute
|
24
|
+
@item_type = attribute.version.item_type
|
25
|
+
@change = change
|
26
|
+
end
|
27
|
+
|
28
|
+
def serialize
|
29
|
+
return if association.name.to_s.underscore.singularize == attribute.request_type.to_s.underscore.singularize
|
30
|
+
change.merge_into_root = true unless model_class.relationally_independent?
|
31
|
+
change.add_diff(
|
32
|
+
attribute: attribute_name,
|
33
|
+
old: find_record(attribute.diff[0]),
|
34
|
+
new: find_record(attribute.diff[1]),
|
35
|
+
rank: 3,
|
36
|
+
source: self.class.name
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
def attribute_name
|
41
|
+
association.name
|
42
|
+
end
|
43
|
+
|
44
|
+
def association
|
45
|
+
@association ||= item_type
|
46
|
+
.constantize
|
47
|
+
.reflections
|
48
|
+
.detect { |_, a| a.foreign_key.to_sym == attribute.to_sym }&.fetch(1)
|
49
|
+
end
|
50
|
+
|
51
|
+
def find_record(id)
|
52
|
+
return unless id
|
53
|
+
find_associated_version(id).try(:name) || find_current_record(id).try(:name) || "Record no longer exists"
|
54
|
+
end
|
55
|
+
|
56
|
+
def model_class
|
57
|
+
@model_class ||= association.klass
|
58
|
+
end
|
59
|
+
|
60
|
+
def find_current_record(id)
|
61
|
+
model_class.find_by(id: id)
|
62
|
+
end
|
63
|
+
|
64
|
+
def find_associated_version(id)
|
65
|
+
PaperTrail::Version.where(
|
66
|
+
PaperTrail::Version.arel_table[:created_at].lt(attribute.version.created_at)
|
67
|
+
).where(
|
68
|
+
item_id: id,
|
69
|
+
item_type: model_class.name
|
70
|
+
).order(created_at: :desc).first
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
class PaperTrail::RelatedChanges::Serializer
|
2
|
+
Diff = Struct.new(:attribute, :old, :new, :rank, :source_rank, :source, :meta, keyword_init: true) do
|
3
|
+
def initialize(rank: 1, source_rank: 1, **args)
|
4
|
+
super
|
5
|
+
end
|
6
|
+
|
7
|
+
def to_h
|
8
|
+
if ENV['RELATED_CHANGES_DEBUG']
|
9
|
+
super
|
10
|
+
else
|
11
|
+
super.except(:rank, :source, :source_rank, :meta)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def eql?(other)
|
16
|
+
attribute == other.attribute && new == other.new && old == other.old
|
17
|
+
end
|
18
|
+
|
19
|
+
def hash
|
20
|
+
[attribute, new, old].hash
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class PaperTrail::RelatedChanges::Serializer
|
2
|
+
module Polymorphic
|
3
|
+
def self.match(attribute)
|
4
|
+
attribute.version.item_type.constantize.reflections.detect { |_, r| r.polymorphic? && r.belongs_to? }
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.serialize(attribute, change)
|
8
|
+
return if [/_id/, /_type/].any? { |r| attribute.to_s =~ r } # Catch all non-associative attributes
|
9
|
+
change.merge_into_root = true unless attribute.version.model_class.relationally_independent?
|
10
|
+
change.add_diff(
|
11
|
+
attribute: attribute.version.item_type.titleize,
|
12
|
+
old: attribute.diff[0],
|
13
|
+
new: attribute.diff[1],
|
14
|
+
rank: 2,
|
15
|
+
source: self.name
|
16
|
+
)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class PaperTrail::RelatedChanges::Serializer
|
2
|
+
class Skippable
|
3
|
+
def self.match(attribute)
|
4
|
+
!!SKIP_COLUMNS.detect do |sk|
|
5
|
+
sk == attribute.to_sym
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
SKIP_COLUMNS = [
|
10
|
+
:id,
|
11
|
+
:created_at,
|
12
|
+
:updated_at,
|
13
|
+
].freeze
|
14
|
+
|
15
|
+
def self.serialize(*); end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module PaperTrail::VersionModel
|
2
|
+
DISPLAY_NAME_METHODS = [
|
3
|
+
:title,
|
4
|
+
:name,
|
5
|
+
:code
|
6
|
+
].freeze
|
7
|
+
|
8
|
+
def name
|
9
|
+
call_by_name(self.next&.reify || live_record)
|
10
|
+
rescue StandardError
|
11
|
+
call_by_name(live_record)
|
12
|
+
end
|
13
|
+
|
14
|
+
def model_class
|
15
|
+
item_type.constantize
|
16
|
+
end
|
17
|
+
|
18
|
+
def extract(*keys)
|
19
|
+
result = keys.map do |key|
|
20
|
+
(object_changes || {})[key.to_s]&.last || (object || {})[key.to_s]
|
21
|
+
end
|
22
|
+
return result if keys.count > 1
|
23
|
+
result.first
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def live_record
|
29
|
+
model_class.find_by(id: item_id)
|
30
|
+
end
|
31
|
+
|
32
|
+
def call_by_name(record)
|
33
|
+
DISPLAY_NAME_METHODS.map { |meth| record.try(meth) }.compact.first
|
34
|
+
end
|
35
|
+
end
|
metadata
ADDED
@@ -0,0 +1,130 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: paper_trail-related_changes
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Dustin Zeisler
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-03-16 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 6.0.1
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 6.0.1
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: paper_trail
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 10.3.1
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 10.3.1
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: pg
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec-rails
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
description: Find all child ActiveRecord relationships from a given resource and groups
|
70
|
+
thems by request_id.
|
71
|
+
email:
|
72
|
+
- dustin@zeisler.net
|
73
|
+
executables: []
|
74
|
+
extensions: []
|
75
|
+
extra_rdoc_files: []
|
76
|
+
files:
|
77
|
+
- MIT-LICENSE
|
78
|
+
- README.md
|
79
|
+
- Rakefile
|
80
|
+
- app/assets/config/paper_trail_changelog_manifest.js
|
81
|
+
- app/assets/stylesheets/paper_trail/changelog/application.css
|
82
|
+
- app/controllers/paper_trail/related_changes/application_controller.rb
|
83
|
+
- app/controllers/paper_trail/related_changes/base_controller.rb
|
84
|
+
- app/helpers/paper_trail/changelog/application_helper.rb
|
85
|
+
- app/jobs/paper_trail/changelog/application_job.rb
|
86
|
+
- app/mailers/paper_trail/changelog/application_mailer.rb
|
87
|
+
- app/models/paper_trail/related_changes/application_record.rb
|
88
|
+
- app/views/layouts/paper_trail/related_changes/application.html.erb
|
89
|
+
- config/routes.rb
|
90
|
+
- lib/paper_trail/related_changes.rb
|
91
|
+
- lib/paper_trail/related_changes/attribute.rb
|
92
|
+
- lib/paper_trail/related_changes/build_changes.rb
|
93
|
+
- lib/paper_trail/related_changes/change.rb
|
94
|
+
- lib/paper_trail/related_changes/engine.rb
|
95
|
+
- lib/paper_trail/related_changes/grouped_by_request_id.rb
|
96
|
+
- lib/paper_trail/related_changes/hierarchy.rb
|
97
|
+
- lib/paper_trail/related_changes/hierarchy/query.rb
|
98
|
+
- lib/paper_trail/related_changes/relationally_independent.rb
|
99
|
+
- lib/paper_trail/related_changes/serializer.rb
|
100
|
+
- lib/paper_trail/related_changes/serializer/belongs_to.rb
|
101
|
+
- lib/paper_trail/related_changes/serializer/diff.rb
|
102
|
+
- lib/paper_trail/related_changes/serializer/polymorphic.rb
|
103
|
+
- lib/paper_trail/related_changes/serializer/skippable.rb
|
104
|
+
- lib/paper_trail/related_changes/version.rb
|
105
|
+
- lib/paper_trail/related_changes/version_model.rb
|
106
|
+
- lib/tasks/paper_trail/changelog_tasks.rake
|
107
|
+
homepage: https://github.com/zeisler/paper_trail-related_changes
|
108
|
+
licenses:
|
109
|
+
- MIT
|
110
|
+
metadata: {}
|
111
|
+
post_install_message:
|
112
|
+
rdoc_options: []
|
113
|
+
require_paths:
|
114
|
+
- lib
|
115
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
116
|
+
requirements:
|
117
|
+
- - ">="
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
version: '0'
|
120
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
requirements: []
|
126
|
+
rubygems_version: 3.1.2
|
127
|
+
signing_key:
|
128
|
+
specification_version: 4
|
129
|
+
summary: Groups and formats related changes that are recorded with PaperTrail
|
130
|
+
test_files: []
|