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.
Files changed (31) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +435 -0
  5. data/config/locales/en.yml +6 -0
  6. data/config/locales/pt-BR.yml +6 -0
  7. data/lib/generators/paper_trail/human/install_generator.rb +17 -0
  8. data/lib/generators/paper_trail/human/templates/initializer.rb +23 -0
  9. data/lib/paper_trail/human/adapters/formatters/html.rb +44 -0
  10. data/lib/paper_trail/human/adapters/formatters/markdown.rb +32 -0
  11. data/lib/paper_trail/human/adapters/formatters/text.rb +33 -0
  12. data/lib/paper_trail/human/adapters/resolvers/boolean.rb +22 -0
  13. data/lib/paper_trail/human/adapters/resolvers/custom.rb +21 -0
  14. data/lib/paper_trail/human/adapters/resolvers/date.rb +36 -0
  15. data/lib/paper_trail/human/adapters/resolvers/enum.rb +57 -0
  16. data/lib/paper_trail/human/adapters/resolvers/number.rb +62 -0
  17. data/lib/paper_trail/human/adapters/resolvers/relation.rb +41 -0
  18. data/lib/paper_trail/human/adapters/resolvers/text.rb +29 -0
  19. data/lib/paper_trail/human/configuration.rb +88 -0
  20. data/lib/paper_trail/human/core/batch_presenter.rb +133 -0
  21. data/lib/paper_trail/human/core/change_extractor.rb +79 -0
  22. data/lib/paper_trail/human/core/event_translator.rb +25 -0
  23. data/lib/paper_trail/human/core/field_formatter.rb +92 -0
  24. data/lib/paper_trail/human/core/presenter.rb +76 -0
  25. data/lib/paper_trail/human/core/timeline.rb +30 -0
  26. data/lib/paper_trail/human/ports/resolver.rb +13 -0
  27. data/lib/paper_trail/human/railtie.rb +26 -0
  28. data/lib/paper_trail/human/version.rb +7 -0
  29. data/lib/paper_trail/human.rb +74 -0
  30. data/lib/paper_trail-human.rb +5 -0
  31. 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