paper_trail-human 0.3.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/CHANGELOG.md +52 -0
- data/LICENSE.txt +21 -0
- data/README.md +435 -0
- data/config/locales/en.yml +6 -0
- data/config/locales/pt-BR.yml +6 -0
- data/lib/generators/paper_trail/human/install_generator.rb +17 -0
- data/lib/generators/paper_trail/human/templates/initializer.rb +23 -0
- data/lib/paper_trail/human/adapters/formatters/html.rb +44 -0
- data/lib/paper_trail/human/adapters/formatters/markdown.rb +32 -0
- data/lib/paper_trail/human/adapters/formatters/text.rb +33 -0
- data/lib/paper_trail/human/adapters/resolvers/boolean.rb +22 -0
- data/lib/paper_trail/human/adapters/resolvers/custom.rb +21 -0
- data/lib/paper_trail/human/adapters/resolvers/date.rb +36 -0
- data/lib/paper_trail/human/adapters/resolvers/enum.rb +57 -0
- data/lib/paper_trail/human/adapters/resolvers/number.rb +62 -0
- data/lib/paper_trail/human/adapters/resolvers/relation.rb +41 -0
- data/lib/paper_trail/human/adapters/resolvers/text.rb +29 -0
- data/lib/paper_trail/human/configuration.rb +88 -0
- data/lib/paper_trail/human/core/batch_presenter.rb +133 -0
- data/lib/paper_trail/human/core/change_extractor.rb +79 -0
- data/lib/paper_trail/human/core/event_translator.rb +25 -0
- data/lib/paper_trail/human/core/field_formatter.rb +92 -0
- data/lib/paper_trail/human/core/presenter.rb +76 -0
- data/lib/paper_trail/human/core/timeline.rb +30 -0
- data/lib/paper_trail/human/ports/resolver.rb +13 -0
- data/lib/paper_trail/human/railtie.rb +26 -0
- data/lib/paper_trail/human/version.rb +7 -0
- data/lib/paper_trail/human.rb +74 -0
- data/lib/paper_trail-human.rb +5 -0
- metadata +100 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Adapters
|
|
6
|
+
module Resolvers
|
|
7
|
+
class Boolean
|
|
8
|
+
include Ports::Resolver
|
|
9
|
+
|
|
10
|
+
def initialize(true_label: 'Yes', false_label: 'No', **)
|
|
11
|
+
@true_label = true_label
|
|
12
|
+
@false_label = false_label
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def resolve(value)
|
|
16
|
+
value ? @true_label : @false_label
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Adapters
|
|
6
|
+
module Resolvers
|
|
7
|
+
class Custom
|
|
8
|
+
include Ports::Resolver
|
|
9
|
+
|
|
10
|
+
def initialize(resolve:, **)
|
|
11
|
+
@proc = resolve
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(value)
|
|
15
|
+
@proc.call(value)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Adapters
|
|
6
|
+
module Resolvers
|
|
7
|
+
class Date
|
|
8
|
+
include Ports::Resolver
|
|
9
|
+
|
|
10
|
+
def initialize(format: '%Y-%m-%d', **)
|
|
11
|
+
@format = format
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(value)
|
|
15
|
+
date = parse_date(value)
|
|
16
|
+
return value unless date
|
|
17
|
+
|
|
18
|
+
date.strftime(@format)
|
|
19
|
+
rescue ArgumentError
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def parse_date(value)
|
|
26
|
+
return value if value.respond_to?(:strftime)
|
|
27
|
+
|
|
28
|
+
::Date.parse(value.to_s)
|
|
29
|
+
rescue ArgumentError, TypeError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Adapters
|
|
6
|
+
module Resolvers
|
|
7
|
+
class Enum
|
|
8
|
+
include Ports::Resolver
|
|
9
|
+
|
|
10
|
+
def initialize(class_name: nil, method: :label, mapping: nil, from_model: nil, **options)
|
|
11
|
+
@class_name = class_name || options[:class].to_s
|
|
12
|
+
@method = method
|
|
13
|
+
@mapping = mapping
|
|
14
|
+
@from_model = from_model
|
|
15
|
+
@field = options[:field]&.to_s
|
|
16
|
+
@labels = options[:labels]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resolve(value)
|
|
20
|
+
return resolve_from_model(value) if @from_model
|
|
21
|
+
return @mapping[value] || value if @mapping
|
|
22
|
+
|
|
23
|
+
klass = Object.const_get(@class_name)
|
|
24
|
+
if klass.respond_to?(@method)
|
|
25
|
+
klass.public_send(@method, value) || value
|
|
26
|
+
else
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
rescue NameError
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def resolve_from_model(value)
|
|
36
|
+
klass = Object.const_get(@from_model)
|
|
37
|
+
enum_mapping = klass.defined_enums[@field]
|
|
38
|
+
return value unless enum_mapping
|
|
39
|
+
|
|
40
|
+
key = enum_mapping.key(value) || enum_mapping.key(value.to_i)
|
|
41
|
+
return value unless key
|
|
42
|
+
|
|
43
|
+
return @labels[key.to_sym] || @labels[key.to_s] || humanize(key) if @labels
|
|
44
|
+
|
|
45
|
+
humanize(key)
|
|
46
|
+
rescue NameError
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def humanize(str)
|
|
51
|
+
str.to_s.tr('_', ' ').then { |s| "#{s[0].upcase}#{s[1..]}" }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Adapters
|
|
6
|
+
module Resolvers
|
|
7
|
+
class Number
|
|
8
|
+
include Ports::Resolver
|
|
9
|
+
|
|
10
|
+
# rubocop:disable Metrics/ParameterLists
|
|
11
|
+
def initialize(format: :default, unit: nil, precision: 2, delimiter: ',', separator: '.', **)
|
|
12
|
+
# rubocop:enable Metrics/ParameterLists
|
|
13
|
+
@format = format
|
|
14
|
+
@unit = unit
|
|
15
|
+
@precision = precision
|
|
16
|
+
@delimiter = delimiter
|
|
17
|
+
@separator = separator
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolve(value)
|
|
21
|
+
num = to_number(value)
|
|
22
|
+
return value unless num
|
|
23
|
+
|
|
24
|
+
case @format
|
|
25
|
+
when :currency then format_currency(num)
|
|
26
|
+
when :percentage then format_percentage(num)
|
|
27
|
+
else format_number(num)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def to_number(value)
|
|
34
|
+
return value if value.is_a?(Numeric)
|
|
35
|
+
|
|
36
|
+
Float(value)
|
|
37
|
+
rescue ArgumentError, TypeError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def format_number(num)
|
|
42
|
+
int_part, dec_part = rounded(num).split('.')
|
|
43
|
+
int_with_delimiters = int_part.gsub(/(\d)(?=(\d{3})+(?!\d))/, "\\1#{@delimiter}")
|
|
44
|
+
dec_part ? "#{int_with_delimiters}#{@separator}#{dec_part}" : int_with_delimiters
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def format_currency(num)
|
|
48
|
+
"#{@unit} #{format_number(num)}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def format_percentage(num)
|
|
52
|
+
"#{format_number(num)}%"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def rounded(num)
|
|
56
|
+
format("%<n>.#{@precision}f", n: num)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Adapters
|
|
6
|
+
module Resolvers
|
|
7
|
+
class Relation
|
|
8
|
+
include Ports::Resolver
|
|
9
|
+
|
|
10
|
+
def initialize(class_name: nil, attribute: :name, cache: nil, **options)
|
|
11
|
+
@class_name = class_name || options[:class].to_s
|
|
12
|
+
@attribute = attribute
|
|
13
|
+
@cache = cache || {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resolve(value)
|
|
17
|
+
return @cache[value] || @cache[value.to_i] || value if @cache.any?
|
|
18
|
+
|
|
19
|
+
klass = safe_const_get(@class_name)
|
|
20
|
+
return value unless klass
|
|
21
|
+
|
|
22
|
+
record = klass.find_by(id: value)
|
|
23
|
+
record&.public_send(@attribute) || value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def safe_const_get(name)
|
|
29
|
+
klass = Object.const_get(name)
|
|
30
|
+
return klass if defined?(ActiveRecord::Base) && klass < ActiveRecord::Base
|
|
31
|
+
return klass if klass.respond_to?(:find_by)
|
|
32
|
+
|
|
33
|
+
nil
|
|
34
|
+
rescue NameError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Adapters
|
|
6
|
+
module Resolvers
|
|
7
|
+
class Text
|
|
8
|
+
include Ports::Resolver
|
|
9
|
+
|
|
10
|
+
DEFAULT_MAX_LENGTH = 80
|
|
11
|
+
|
|
12
|
+
def initialize(max_length: DEFAULT_MAX_LENGTH, show_diff_stats: false, **)
|
|
13
|
+
@max_length = max_length
|
|
14
|
+
@show_diff_stats = show_diff_stats
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def resolve(value)
|
|
18
|
+
text = value.to_s
|
|
19
|
+
return text if text.length <= @max_length
|
|
20
|
+
|
|
21
|
+
truncated = "#{text[0, @max_length]}..."
|
|
22
|
+
truncated += " (#{text.length} chars)" if @show_diff_stats
|
|
23
|
+
truncated
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
class Configuration
|
|
6
|
+
DEFAULT_IGNORED_FIELDS = %w[id created_at updated_at].freeze
|
|
7
|
+
|
|
8
|
+
attr_accessor :whodunnit_resolver, :extend_version_model, :field_name_resolver,
|
|
9
|
+
:translate_events, :after_format
|
|
10
|
+
attr_reader :ignored_fields
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@model_configs = {}
|
|
14
|
+
@ignored_fields = DEFAULT_IGNORED_FIELDS.dup
|
|
15
|
+
@whodunnit_resolver = nil
|
|
16
|
+
@field_name_resolver = nil
|
|
17
|
+
@translate_events = false
|
|
18
|
+
@extend_version_model = false
|
|
19
|
+
@after_format = nil
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ignored_fields=(fields)
|
|
24
|
+
@ignored_fields = Array(fields).map(&:to_s)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def register(model_name, &)
|
|
28
|
+
model_config = ModelConfig.new
|
|
29
|
+
yield(model_config)
|
|
30
|
+
@mutex.synchronize { @model_configs[model_name.to_s] = model_config.freeze }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def config_for(model_name)
|
|
34
|
+
@model_configs[model_name.to_s]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def resolve_whodunnit(id)
|
|
38
|
+
return id unless whodunnit_resolver
|
|
39
|
+
|
|
40
|
+
whodunnit_resolver.call(id)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resolve_item_name(version)
|
|
44
|
+
model_config = config_for(version.item_type)
|
|
45
|
+
return nil unless model_config&.item_name_attribute
|
|
46
|
+
|
|
47
|
+
attr = model_config.item_name_attribute
|
|
48
|
+
return attr.call(version) if attr.respond_to?(:call)
|
|
49
|
+
|
|
50
|
+
item = find_item(version)
|
|
51
|
+
item&.public_send(attr)
|
|
52
|
+
rescue NoMethodError, ActiveRecord::RecordNotFound
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def find_item(version)
|
|
59
|
+
klass = Object.const_get(version.item_type)
|
|
60
|
+
klass.find_by(id: version.item_id)
|
|
61
|
+
rescue NameError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class ModelConfig
|
|
67
|
+
attr_reader :fields, :item_name_attribute
|
|
68
|
+
|
|
69
|
+
def initialize
|
|
70
|
+
@fields = {}
|
|
71
|
+
@item_name_attribute = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def field(name, type, **options)
|
|
75
|
+
@fields[name.to_s] = { type: type, options: options }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def item_name(attribute_or_lambda)
|
|
79
|
+
@item_name_attribute = attribute_or_lambda
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def freeze
|
|
83
|
+
@fields.freeze
|
|
84
|
+
super
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Core
|
|
6
|
+
class BatchPresenter
|
|
7
|
+
def initialize(configuration)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
@change_extractor = ChangeExtractor.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(versions, only: nil, except: nil)
|
|
13
|
+
versions_data = versions.map { |v| [v, @change_extractor.call(v)] }
|
|
14
|
+
preloaded = preload_relations(versions_data)
|
|
15
|
+
|
|
16
|
+
versions_data.map do |version, changes|
|
|
17
|
+
format_version(version, changes, preloaded, only: only, except: except)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def format_version(version, changes, preloaded, only: nil, except: nil)
|
|
24
|
+
model_config = @configuration.config_for(version.item_type)
|
|
25
|
+
formatter = FieldFormatter.new(
|
|
26
|
+
model_config,
|
|
27
|
+
version.item_type,
|
|
28
|
+
field_name_resolver: @configuration.field_name_resolver,
|
|
29
|
+
preloaded: preloaded
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
result = {
|
|
33
|
+
user: @configuration.resolve_whodunnit(version.whodunnit),
|
|
34
|
+
event: EventTranslator.call(version.event, translate: @configuration.translate_events),
|
|
35
|
+
model: version.item_type,
|
|
36
|
+
item_id: version.item_id,
|
|
37
|
+
created_at: version.created_at,
|
|
38
|
+
fields: build_fields(changes, formatter, version.event, only: only, except: except)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
item_name = @configuration.resolve_item_name(version)
|
|
42
|
+
result[:item_name] = item_name if item_name
|
|
43
|
+
|
|
44
|
+
apply_after_format(result, version)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_fields(changes, formatter, event, only: nil, except: nil)
|
|
48
|
+
changes
|
|
49
|
+
.reject { |field, _| @configuration.ignored_fields.include?(field.to_s) }
|
|
50
|
+
.select { |field, _| filter_field?(field, only, except) }
|
|
51
|
+
.map { |field, values| format_field(formatter, field, values, event) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def filter_field?(field, only, except)
|
|
55
|
+
field_s = field.to_s
|
|
56
|
+
return only.map(&:to_s).include?(field_s) if only
|
|
57
|
+
|
|
58
|
+
return !except.map(&:to_s).include?(field_s) if except
|
|
59
|
+
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def format_field(formatter, field, values, event)
|
|
64
|
+
previous_value, new_value = Array(values)
|
|
65
|
+
result = formatter.call(field, previous_value, new_value)
|
|
66
|
+
|
|
67
|
+
case event
|
|
68
|
+
when 'create'
|
|
69
|
+
result.delete(:previous_value)
|
|
70
|
+
when 'destroy'
|
|
71
|
+
result.delete(:value)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def preload_relations(versions_data)
|
|
78
|
+
relation_fields = collect_relation_fields(versions_data)
|
|
79
|
+
return {} if relation_fields.empty?
|
|
80
|
+
|
|
81
|
+
relation_fields.each_with_object({}) do |(key, ids), cache|
|
|
82
|
+
class_name, attribute = key
|
|
83
|
+
cache[key] = load_records(class_name, attribute, ids)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def collect_relation_fields(versions_data)
|
|
88
|
+
result = Hash.new { |h, k| h[k] = Set.new }
|
|
89
|
+
|
|
90
|
+
versions_data.each do |version, changes|
|
|
91
|
+
collect_from_version(result, version, changes)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
result
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def collect_from_version(result, version, changes)
|
|
98
|
+
model_config = @configuration.config_for(version.item_type)
|
|
99
|
+
return unless model_config
|
|
100
|
+
|
|
101
|
+
changes.each do |field, values|
|
|
102
|
+
field_cfg = model_config.fields[field.to_s]
|
|
103
|
+
next unless field_cfg && field_cfg[:type] == :relation
|
|
104
|
+
|
|
105
|
+
key = relation_key(field_cfg)
|
|
106
|
+
Array(values).compact.each { |v| result[key].add(v) }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def relation_key(field_cfg)
|
|
111
|
+
class_name = field_cfg[:options][:class_name] || field_cfg[:options][:class].to_s
|
|
112
|
+
attribute = field_cfg[:options][:attribute] || :name
|
|
113
|
+
[class_name, attribute]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def load_records(class_name, attribute, ids)
|
|
117
|
+
klass = Object.const_get(class_name)
|
|
118
|
+
klass.where(id: ids.to_a).to_h do |record|
|
|
119
|
+
[record.id, record.public_send(attribute)]
|
|
120
|
+
end
|
|
121
|
+
rescue NameError
|
|
122
|
+
{}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def apply_after_format(result, version)
|
|
126
|
+
return result unless @configuration.after_format
|
|
127
|
+
|
|
128
|
+
@configuration.after_format.call(result, version)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'bigdecimal'
|
|
6
|
+
|
|
7
|
+
module PaperTrail
|
|
8
|
+
module Human
|
|
9
|
+
module Core
|
|
10
|
+
class ChangeExtractor
|
|
11
|
+
YAML_PERMITTED_CLASSES = [Time, Date, DateTime, BigDecimal, Symbol].freeze
|
|
12
|
+
|
|
13
|
+
def call(version)
|
|
14
|
+
changes = extract_object_changes(version)
|
|
15
|
+
return changes if changes
|
|
16
|
+
|
|
17
|
+
warn_missing_object_changes(version) if version.event == 'update'
|
|
18
|
+
infer_from_object(version)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def warn_missing_object_changes(version)
|
|
24
|
+
return if @warned
|
|
25
|
+
|
|
26
|
+
@warned = true
|
|
27
|
+
message = "[paper_trail-human] Version ##{version.id} (update) has no object_changes. " \
|
|
28
|
+
'Add the object_changes column to your versions table for full update tracking.'
|
|
29
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
30
|
+
Rails.logger.warn(message)
|
|
31
|
+
else
|
|
32
|
+
warn message
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def extract_object_changes(version)
|
|
37
|
+
return nil unless version.respond_to?(:object_changes) && version.object_changes
|
|
38
|
+
|
|
39
|
+
raw = version.object_changes
|
|
40
|
+
parse(raw)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def infer_from_object(version)
|
|
44
|
+
return {} unless version.respond_to?(:object) && version.object
|
|
45
|
+
|
|
46
|
+
parsed = parse(version.object)
|
|
47
|
+
return {} unless parsed.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
case version.event
|
|
50
|
+
when 'create'
|
|
51
|
+
parsed.transform_values { |v| [nil, v] }
|
|
52
|
+
when 'destroy'
|
|
53
|
+
parsed.transform_values { |v| [v, nil] }
|
|
54
|
+
else
|
|
55
|
+
{}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse(raw)
|
|
60
|
+
return raw if raw.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
JSON.parse(raw)
|
|
63
|
+
rescue JSON::ParserError
|
|
64
|
+
YAML.safe_load(raw, permitted_classes: yaml_permitted_classes, aliases: true)
|
|
65
|
+
rescue StandardError
|
|
66
|
+
{}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def yaml_permitted_classes
|
|
70
|
+
classes = YAML_PERMITTED_CLASSES.dup
|
|
71
|
+
classes << ActiveSupport::TimeWithZone if defined?(ActiveSupport::TimeWithZone)
|
|
72
|
+
classes << ActiveSupport::TimeZone if defined?(ActiveSupport::TimeZone)
|
|
73
|
+
classes << ActiveSupport::Duration if defined?(ActiveSupport::Duration)
|
|
74
|
+
classes
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Core
|
|
6
|
+
module EventTranslator
|
|
7
|
+
DEFAULT_LABELS = {
|
|
8
|
+
'create' => 'Created',
|
|
9
|
+
'update' => 'Updated',
|
|
10
|
+
'destroy' => 'Destroyed'
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def self.call(event, translate:)
|
|
14
|
+
return event unless translate
|
|
15
|
+
|
|
16
|
+
return event unless defined?(I18n)
|
|
17
|
+
|
|
18
|
+
I18n.t("paper_trail_human.events.#{event}", default: nil) || DEFAULT_LABELS[event] || event
|
|
19
|
+
rescue I18n::InvalidLocale, I18n::InvalidLocaleData
|
|
20
|
+
DEFAULT_LABELS[event] || event
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Core
|
|
6
|
+
class FieldFormatter
|
|
7
|
+
RESOLVER_MAP = {
|
|
8
|
+
relation: 'PaperTrail::Human::Adapters::Resolvers::Relation',
|
|
9
|
+
enum: 'PaperTrail::Human::Adapters::Resolvers::Enum',
|
|
10
|
+
boolean: 'PaperTrail::Human::Adapters::Resolvers::Boolean',
|
|
11
|
+
custom: 'PaperTrail::Human::Adapters::Resolvers::Custom',
|
|
12
|
+
text: 'PaperTrail::Human::Adapters::Resolvers::Text',
|
|
13
|
+
date: 'PaperTrail::Human::Adapters::Resolvers::Date',
|
|
14
|
+
number: 'PaperTrail::Human::Adapters::Resolvers::Number'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(model_config, item_type, field_name_resolver: nil, preloaded: nil)
|
|
18
|
+
@model_config = model_config
|
|
19
|
+
@item_type = item_type
|
|
20
|
+
@field_name_resolver = field_name_resolver
|
|
21
|
+
@preloaded = preloaded || {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(field_name, previous_value, new_value)
|
|
25
|
+
config = field_config(field_name)
|
|
26
|
+
resolver = build_resolver(config, field_name)
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
field: human_field_name(field_name),
|
|
30
|
+
previous_value: resolve_value(resolver, previous_value),
|
|
31
|
+
value: resolve_value(resolver, new_value)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def field_config(field_name)
|
|
38
|
+
return nil unless @model_config
|
|
39
|
+
|
|
40
|
+
@model_config.fields[field_name.to_s]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build_resolver(config, field_name = nil)
|
|
44
|
+
return nil unless config
|
|
45
|
+
|
|
46
|
+
class_name = RESOLVER_MAP[config[:type]]
|
|
47
|
+
raise Error, "Unknown resolver type: #{config[:type]}" unless class_name
|
|
48
|
+
|
|
49
|
+
klass = Object.const_get(class_name)
|
|
50
|
+
opts = config[:options]
|
|
51
|
+
opts = opts.merge(cache: relation_cache(config)) if config[:type] == :relation
|
|
52
|
+
opts = opts.merge(field: field_name) if config[:type] == :enum && opts[:from_model]
|
|
53
|
+
klass.new(**opts)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def relation_cache(config)
|
|
57
|
+
class_name = config[:options][:class_name] || config[:options][:class].to_s
|
|
58
|
+
attribute = config[:options][:attribute] || :name
|
|
59
|
+
@preloaded[[class_name, attribute]] || {}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def resolve_value(resolver, value)
|
|
63
|
+
return value unless resolver
|
|
64
|
+
return value if value.nil?
|
|
65
|
+
|
|
66
|
+
resolver.resolve(value)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def human_field_name(field_name)
|
|
70
|
+
return @field_name_resolver.call(field_name, @item_type) if @field_name_resolver
|
|
71
|
+
|
|
72
|
+
i18n_field_name(field_name) || default_human_field_name(field_name)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def i18n_field_name(field_name)
|
|
76
|
+
return nil unless defined?(I18n)
|
|
77
|
+
|
|
78
|
+
model_key = @item_type.gsub('::', '/').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
79
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
80
|
+
key = "activerecord.attributes.#{model_key}.#{field_name}"
|
|
81
|
+
I18n.t(key, default: nil)
|
|
82
|
+
rescue I18n::InvalidLocale, I18n::InvalidLocaleData
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def default_human_field_name(field_name)
|
|
87
|
+
field_name.to_s.delete_suffix('_id').tr('_', ' ').then { |s| "#{s[0].upcase}#{s[1..]}" }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|