lethe 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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lethe
4
+ class Configuration
5
+ # Tables Rails and lethe keep for themselves. Active Storage's are left
6
+ # out because attachments are found through has_one_attached and
7
+ # has_many_attached, and declared with destroy_attachments.
8
+ BUILTIN_SKIP_TABLES = %w[
9
+ ar_internal_metadata schema_migrations lethe_erasures
10
+ active_storage_attachments active_storage_blobs active_storage_variant_records
11
+ ].freeze
12
+
13
+ # Where the definitions live, relative to the application root.
14
+ attr_accessor :definition_path
15
+
16
+ # Where lethe:export writes, without extension: .md and .json are added.
17
+ attr_accessor :export_path
18
+
19
+ # Tables to leave out of the search, besides Rails' own.
20
+ attr_accessor :skip_tables
21
+
22
+ # How erased attachments' files go, after the transaction commits:
23
+ # :later through Active Storage's purge job, or :now.
24
+ attr_reader :purge
25
+
26
+ def initialize
27
+ @definition_path = "config/lethe.rb"
28
+ @export_path = "docs/lethe"
29
+ @skip_tables = []
30
+ @purge = :later
31
+ @guess_columns = Hash.new { |hash, key| hash[key] = [] }
32
+ end
33
+
34
+ # More column names to treat as pointing at a subject, per subject:
35
+ # config.guess_columns[User] = %w[follower_id following_id]
36
+ def guess_columns
37
+ GuessColumns.new(@guess_columns)
38
+ end
39
+
40
+ def purge=(value)
41
+ raise ArgumentError, "lethe: purge must be :later or :now" unless %i[later now].include?(value)
42
+
43
+ @purge = value
44
+ end
45
+
46
+ def guess_columns_for(subject_name)
47
+ @guess_columns.fetch(subject_name, []).map(&:to_s)
48
+ end
49
+
50
+ def skipped_table?(table)
51
+ BUILTIN_SKIP_TABLES.include?(table) || skip_tables.map(&:to_s).include?(table)
52
+ end
53
+
54
+ # Lets guess_columns be keyed by the class or its name.
55
+ class GuessColumns
56
+ def initialize(store)
57
+ @store = store
58
+ end
59
+
60
+ def [](subject)
61
+ @store[key(subject)]
62
+ end
63
+
64
+ def []=(subject, columns)
65
+ @store[key(subject)] = Array(columns).map(&:to_s)
66
+ end
67
+
68
+ private
69
+
70
+ def key(subject)
71
+ subject.is_a?(Module) ? subject.name : subject.to_s
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lethe
4
+ # The policies declared for one subject, such as User.
5
+ class Definition
6
+ attr_reader :subject_name, :policies, :hooks
7
+ attr_accessor :subject_policy
8
+
9
+ def initialize(subject_name)
10
+ @subject_name = subject_name
11
+ @policies = []
12
+ @hooks = []
13
+ @subject_policy = nil
14
+ end
15
+
16
+ def hooks_for(moment)
17
+ hooks.select { |hook| hook.moment == moment }
18
+ end
19
+
20
+ # The methods available inside Lethe.define.
21
+ class Builder
22
+ def initialize(definition)
23
+ @definition = definition
24
+ end
25
+
26
+ # Delete the rows. cascade: true deletes them through Active Record,
27
+ # so that dependent: associations, callbacks and attachments go too.
28
+ def destroy(*targets, reason: nil, where: nil, cascade: false)
29
+ add(:destroy, targets, location: caller_location, reason: reason, where: where, cascade: cascade)
30
+ end
31
+
32
+ # Keep the rows but overwrite columns: scrub :posts, set: { body: "" }
33
+ # With for:, lethe:sweep deletes the rows once that time has passed:
34
+ # scrub :orders, set: { phone: nil }, for: 7.years. cascade: true makes
35
+ # that deletion go through Active Record, as with destroy.
36
+ def scrub(*targets, set:, reason: nil, where: nil, cascade: false, **options)
37
+ add(:scrub, targets, location: caller_location, reason: reason, where: where, set: set,
38
+ retention: retention(options), cascade: cascade)
39
+ end
40
+
41
+ # Leave the rows as they are, for a reason: keep :orders, for: 7.years, reason: "..."
42
+ def keep(*targets, reason: nil, where: nil, cascade: false, **options)
43
+ add(:keep, targets, location: caller_location, reason: reason, where: where,
44
+ retention: retention(options), cascade: cascade)
45
+ end
46
+
47
+ # The column does not point at the subject after all (a wrong guess).
48
+ def ignore(*targets, reason: nil)
49
+ add(:ignore, targets, location: caller_location, reason: reason)
50
+ end
51
+
52
+ # Delete Active Storage attachments of the subject.
53
+ def destroy_attachments(*names, reason: nil)
54
+ raise ArgumentError, "lethe: destroy_attachments needs at least one name" if names.empty?
55
+
56
+ location = caller_location
57
+ names.each do |name|
58
+ @definition.policies << Policy.new(
59
+ action: :destroy_attachment, target: Target.attachment(name), reason: reason,
60
+ retention: nil, where: nil, set: nil, cascade: false, location: location
61
+ )
62
+ end
63
+ end
64
+
65
+ # How the subject's own row goes: subject soft_delete: :deleted_at, scrub: { email: nil }
66
+ def subject(destroy: false, soft_delete: nil, scrub: {})
67
+ @definition.subject_policy = SubjectPolicy.new(
68
+ destroy: destroy, soft_delete: soft_delete, scrub: scrub, location: caller_location
69
+ )
70
+ end
71
+
72
+ # Runs inside the erasure's transaction, before anything is deleted.
73
+ def before_erase(&block)
74
+ hook(:before, block)
75
+ end
76
+
77
+ # Runs inside the erasure's transaction, after everything is deleted.
78
+ # Anything that cannot be rolled back, such as a call to another
79
+ # service, belongs in a job enqueued from here.
80
+ def after_erase(&block)
81
+ hook(:after, block)
82
+ end
83
+
84
+ private
85
+
86
+ # for: is a Ruby keyword, so it arrives in the options hash.
87
+ def retention(options)
88
+ unknown = options.keys - [:for]
89
+ raise ArgumentError, "lethe: unknown keyword#{"s" if unknown.size > 1}: #{unknown.join(", ")}" if unknown.any?
90
+
91
+ options[:for]
92
+ end
93
+
94
+ def add(action, targets, location:, reason: nil, where: nil, set: nil, retention: nil, cascade: false)
95
+ raise ArgumentError, "lethe: #{action} needs at least one target" if targets.empty?
96
+
97
+ targets.each do |value|
98
+ @definition.policies << Policy.new(
99
+ action: action, target: Target.parse(value), reason: reason,
100
+ retention: retention, where: where, set: set, cascade: cascade ? true : false, location: location
101
+ )
102
+ end
103
+ end
104
+
105
+ def hook(moment, block)
106
+ raise ArgumentError, "lethe: #{moment}_erase needs a block" unless block
107
+
108
+ location = caller_locations(2, 1).first
109
+ @definition.hooks << Hook.new(moment: moment, block: block, location: "#{location.path}:#{location.lineno}")
110
+ end
111
+
112
+ def caller_location
113
+ location = caller_locations(2, 1).first
114
+ "#{location.path}:#{location.lineno}"
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lethe
4
+ # Finds everything that points at a subject model, from the schema and the
5
+ # models' associations. Reads table and column definitions only, never rows.
6
+ class Discovery
7
+ def initialize(subject, models:, connection:, configuration:)
8
+ @subject = subject
9
+ @models = models
10
+ @connection = connection
11
+ @configuration = configuration
12
+ end
13
+
14
+ # Returns the references, one per table.column or attachment, sorted by key.
15
+ def call
16
+ found = {}
17
+ [foreign_keys, own_associations, other_associations, attachments, column_names].each do |references|
18
+ references.each do |reference|
19
+ found[reference.key] = found.key?(reference.key) ? found[reference.key].merge(reference) : reference
20
+ end
21
+ end
22
+ found.values.sort_by(&:key)
23
+ end
24
+
25
+ def tables
26
+ @tables ||= @connection.tables.reject { |table| @configuration.skipped_table?(table) }.sort
27
+ end
28
+
29
+ private
30
+
31
+ def subject_table
32
+ @subject.table_name
33
+ end
34
+
35
+ # Foreign key constraints that point at the subject's table.
36
+ def foreign_keys
37
+ tables.flat_map do |table|
38
+ @connection.foreign_keys(table)
39
+ .select { |key| key.to_table == subject_table }
40
+ .map { |key| Reference.column(table, key.column, :foreign_key) }
41
+ end
42
+ end
43
+
44
+ # The subject's has_many, has_one and has_and_belongs_to_many.
45
+ def own_associations
46
+ @subject.reflect_on_all_associations.filter_map do |reflection|
47
+ # Through associations read other tables; has_one_attached's own
48
+ # associations are reported as attachments instead.
49
+ next if reflection.through_reflection? || reflection.class_name == "ActiveStorage::Attachment"
50
+
51
+ case reflection.macro
52
+ when :has_many, :has_one
53
+ table = table_of(reflection)
54
+ next unless table
55
+
56
+ via = reflection.options[:as] ? :polymorphic : :association
57
+ Reference.column(table, reflection.foreign_key, via)
58
+ when :has_and_belongs_to_many
59
+ Reference.column(reflection.join_table, reflection.foreign_key, :join_table)
60
+ end
61
+ end
62
+ end
63
+
64
+ # belongs_to in other models that points at the subject. A polymorphic
65
+ # belongs_to might point at anything, so it is only a guess.
66
+ def other_associations
67
+ @models.flat_map do |model|
68
+ next [] if model.abstract_class? || !table_exists?(model)
69
+
70
+ model.reflect_on_all_associations(:belongs_to).filter_map do |reflection|
71
+ if reflection.polymorphic?
72
+ Reference.column(model.table_name, reflection.foreign_key, :polymorphic_guess)
73
+ elsif table_of(reflection) == subject_table
74
+ Reference.column(model.table_name, reflection.foreign_key, :association)
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ def attachments
81
+ return [] unless @subject.respond_to?(:reflect_on_all_attachments)
82
+
83
+ @subject.reflect_on_all_attachments.map { |reflection| Reference.attachment(reflection.name) }
84
+ end
85
+
86
+ # Columns named like user_id or *_user_id, and the ones configured.
87
+ def column_names
88
+ # An STI subclass is stored in its base class's table, and other tables
89
+ # point at it as user_id, not admin_id.
90
+ singular = @subject.base_class.model_name.singular
91
+ extra = (@configuration.guess_columns_for(@subject.name) + @configuration.guess_columns_for(@subject.base_class.name)).uniq
92
+ tables.flat_map do |table|
93
+ @connection.columns(table).filter_map do |column|
94
+ name = column.name
95
+ next if table == subject_table && name == @subject.primary_key
96
+
97
+ if name == "#{singular}_id" || name.end_with?("_#{singular}_id") || extra.include?(name)
98
+ Reference.column(table, name, :column_name)
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ # The table an association reads, or nil when its class cannot be loaded.
105
+ def table_of(reflection)
106
+ reflection.klass.table_name
107
+ rescue NameError, ArgumentError
108
+ nil
109
+ end
110
+
111
+ def table_exists?(model)
112
+ tables.include?(model.table_name)
113
+ rescue ActiveRecord::ActiveRecordError
114
+ false
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lethe
4
+ # lethe reaches rows through plain table classes, which know nothing of the
5
+ # application's enums: { status: "answered" } would become status = NULL
6
+ # and match nothing. These turn enum names into the values stored, using
7
+ # the application's model for the table.
8
+ module Enums
9
+ # The hash with enum names replaced by their stored values. Values that
10
+ # are not enum names, such as nil or a stored value, stay as they are.
11
+ def self.cast(model, hash)
12
+ return hash unless model && hash.is_a?(Hash)
13
+
14
+ enums = model.defined_enums
15
+ hash.to_h do |column, value|
16
+ mapping = enums[column.to_s]
17
+ [column, mapping ? cast_value(mapping, value) : value]
18
+ end
19
+ end
20
+
21
+ # The names in hash that are neither a name nor a stored value of the
22
+ # column's enum, as [column, value] pairs.
23
+ def self.unknown(model, hash)
24
+ return [] unless model && hash.is_a?(Hash)
25
+
26
+ hash.flat_map do |column, value|
27
+ mapping = model.defined_enums[column.to_s]
28
+ next [] unless mapping
29
+
30
+ Array(value).reject { |item| item.nil? || item.is_a?(Proc) || known?(mapping, item) }.map { |item| [column.to_s, item] }
31
+ end
32
+ end
33
+
34
+ def self.cast_value(mapping, value)
35
+ return value.map { |item| cast_value(mapping, item) } if value.is_a?(Array)
36
+ return mapping[value.to_s] if (value.is_a?(String) || value.is_a?(Symbol)) && mapping.key?(value.to_s)
37
+
38
+ value
39
+ end
40
+
41
+ def self.known?(mapping, item)
42
+ mapping.key?(item.to_s) || mapping.value?(item)
43
+ end
44
+ private_class_method :cast_value, :known?
45
+ end
46
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lethe
4
+ # Carries out a Plan in one transaction. Attachments are detached inside
5
+ # it; their files are purged only after it commits, since files cannot be
6
+ # rolled back.
7
+ class Eraser
8
+ Erasure = Data.define(:subject, :id, :counts)
9
+
10
+ def initialize(plan, configuration:, ledger:)
11
+ @plan = plan
12
+ @record = plan.record
13
+ @definition = plan.definition
14
+ @configuration = configuration
15
+ @ledger = ledger
16
+ end
17
+
18
+ def call
19
+ blobs = []
20
+ counts = {}
21
+ ActiveRecord::Base.transaction do
22
+ @definition.hooks_for(:before).each { |hook| hook.block.call(@record) }
23
+ # { "posts.user_id" => { "destroy" => 1, "scrub" => 4 } }: a table can have a policy per condition.
24
+ @plan.each do |step|
25
+ rows = perform(step, blobs)
26
+ (counts[step.key] ||= Hash.new(0))[step.kind.to_s] += rows
27
+ end
28
+ @definition.hooks_for(:after).each { |hook| hook.block.call(@record) }
29
+ Ledger.record(subject_type, @record.id, "erase", counts) if @ledger
30
+ end
31
+
32
+ erasure = Erasure.new(subject: subject_type, id: @record.id, counts: counts)
33
+ ActiveRecord.after_all_transactions_commit do
34
+ failed = purge(blobs)
35
+ ActiveSupport::Notifications.instrument(
36
+ "erase.lethe", subject: erasure.subject, id: erasure.id, counts: counts, purge_failures: failed
37
+ )
38
+ end
39
+ erasure
40
+ end
41
+
42
+ private
43
+
44
+ # The definition's subject, so that an STI subclass with a definition of
45
+ # its own is swept by its own retention.
46
+ def subject_type
47
+ @definition.subject_name
48
+ end
49
+
50
+ def perform(step, blobs)
51
+ case step.kind
52
+ when :attachment then detach(step.policy.target.name, blobs)
53
+ when :destroy then destroy(step)
54
+ when :scrub then scrub(step)
55
+ when :keep then 0
56
+ when :subject then erase_subject(step)
57
+ end
58
+ end
59
+
60
+ def destroy(step)
61
+ if step.policy.cascade
62
+ step.location.model.unscoped.where(step.conditions).destroy_all.size
63
+ else
64
+ rows(step).where(step.conditions).delete_all
65
+ end
66
+ end
67
+
68
+ def scrub(step)
69
+ set = step.policy.set
70
+ unless step.policy.computed_set?
71
+ return rows(step).where(step.conditions).update_all(Enums.cast(step.location.model, set))
72
+ end
73
+
74
+ count = 0
75
+ rows(step).where(step.conditions).find_each do |row|
76
+ row.update_columns(Enums.cast(step.location.model, evaluate(set, row)))
77
+ count += 1
78
+ end
79
+ count
80
+ end
81
+
82
+ def erase_subject(step)
83
+ policy = step.policy
84
+ relation = rows(step).where(step.conditions)
85
+ return relation.delete_all if policy.destroy
86
+
87
+ # The first erasure's time stays, so that retention counts from it.
88
+ relation.where(policy.soft_delete => nil).update_all(policy.soft_delete => Time.current)
89
+ relation.update_all(Enums.cast(@record.class, evaluate(policy.scrub, @record))) unless policy.scrub.empty?
90
+ relation.count
91
+ end
92
+
93
+ def detach(name, blobs)
94
+ attached = @record.public_send(name)
95
+ attachments = attached.respond_to?(:attachments) ? attached.attachments.to_a : [attached.attachment].compact
96
+ blobs.concat(attachments.map(&:blob))
97
+ attached.detach if attachments.any?
98
+ attachments.size
99
+ end
100
+
101
+ # The erasure has committed by now, so a file that cannot be purged must
102
+ # not make it look as if it failed. Returns the blob ids that could not be
103
+ # purged, for the notification; their blobs are already detached.
104
+ def purge(blobs)
105
+ blobs.filter_map do |blob|
106
+ (@configuration.purge == :now) ? blob.purge : blob.purge_later
107
+ nil
108
+ rescue => error
109
+ ActiveRecord::Base.logger&.warn("lethe: could not purge blob #{blob.id}: #{error.class}: #{error.message}")
110
+ blob.id
111
+ end
112
+ end
113
+
114
+ def evaluate(set, row)
115
+ set.to_h do |column, value|
116
+ next [column, value] unless value.is_a?(Proc)
117
+
118
+ [column, value.arity.zero? ? value.call : value.call(row)]
119
+ end
120
+ end
121
+
122
+ def rows(step)
123
+ Rows.for(step.location.table)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module Lethe
7
+ # Writes what happens to each kind of data when a subject leaves, as a
8
+ # Markdown table for people and as JSON for tools. Only covered references
9
+ # appear; ignored ones do not point at the subject.
10
+ class Exporter
11
+ def initialize(report, definitions:, models:, root:, definition_path:, locale: I18n.default_locale)
12
+ @report = report
13
+ @definitions = definitions
14
+ @models = models
15
+ @root = Pathname(root)
16
+ @definition_path = definition_path
17
+ @locale = locale
18
+ end
19
+
20
+ def markdown
21
+ sections = subjects.map { |subject| markdown_section(subject) }
22
+ "<!-- #{t("lethe.export.generated", path: relative(@definition_path))} -->\n\n#{sections.join("\n")}"
23
+ end
24
+
25
+ def json
26
+ ::JSON.pretty_generate(
27
+ "version" => 1,
28
+ "subjects" => subjects.map { |subject| json_subject(subject) }
29
+ ) + "\n"
30
+ end
31
+
32
+ private
33
+
34
+ def subjects
35
+ @definitions.map(&:subject_name).select { |name| @report.subjects.include?(name) }.sort
36
+ end
37
+
38
+ def rows(subject)
39
+ @report.results
40
+ .select { |result| result.subject == subject && result.status == :covered && result.reference }
41
+ .sort_by(&:key)
42
+ .flat_map { |result| result.policies.reject(&:ignore?).map { |policy| [result.reference, policy] } }
43
+ end
44
+
45
+ def markdown_section(subject)
46
+ lines = [
47
+ "# #{t("lethe.export.title", subject: subject_label(subject))}",
48
+ "",
49
+ "| #{t("lethe.export.data")} | #{t("lethe.export.action")} | #{t("lethe.export.retention")} | #{t("lethe.export.reason")} |",
50
+ "| --- | --- | --- | --- |"
51
+ ]
52
+ definition = definition_for(subject)
53
+ if definition.subject_policy
54
+ lines << row([t("lethe.export.subject_row", subject: subject_label(subject)), subject_action(definition.subject_policy), "", ""])
55
+ end
56
+ rows(subject).each do |reference, policy|
57
+ lines << row([label(subject, reference), action(policy), retention(policy.retention), policy.reason.to_s])
58
+ end
59
+ lines.join("\n") + "\n"
60
+ end
61
+
62
+ def json_subject(subject)
63
+ definition = definition_for(subject)
64
+ subject_policy = definition.subject_policy
65
+ {
66
+ "subject" => subject,
67
+ "subject_row" => subject_policy && {
68
+ "destroy" => subject_policy.destroy ? true : false,
69
+ "soft_delete" => subject_policy.soft_delete&.to_s,
70
+ "scrub" => subject_policy.scrub.keys.map(&:to_s)
71
+ }.compact,
72
+ "data" => rows(subject).map do |reference, policy|
73
+ {
74
+ "key" => reference.key,
75
+ "table" => reference.table,
76
+ "column" => reference.column,
77
+ "attachment" => reference.attachment,
78
+ "label" => label(subject, reference),
79
+ "action" => policy.action.to_s,
80
+ "where" => policy.where&.transform_keys(&:to_s),
81
+ "set" => policy.set&.keys&.map(&:to_s),
82
+ "retention_seconds" => policy.retention&.to_i,
83
+ "retention" => policy.retention && retention(policy.retention),
84
+ "reason" => policy.reason
85
+ }.compact
86
+ end
87
+ }.compact
88
+ end
89
+
90
+ def definition_for(subject)
91
+ @definitions.find { |definition| definition.subject_name == subject }
92
+ end
93
+
94
+ def subject_model(subject)
95
+ subject.safe_constantize
96
+ end
97
+
98
+ def subject_label(subject)
99
+ model = subject_model(subject)
100
+ model ? model.model_name.human(locale: @locale) : subject
101
+ end
102
+
103
+ def label(subject, reference)
104
+ if reference.attachment
105
+ model = subject_model(subject)
106
+ name = model ? model.human_attribute_name(reference.attachment, locale: @locale) : reference.attachment
107
+ return t("lethe.export.attachment", name: name)
108
+ end
109
+
110
+ model = @models.find { |candidate| candidate.table_name == reference.table && candidate.base_class == candidate }
111
+ base = model ? model.model_name.human(locale: @locale) : reference.table
112
+ conventional = subject_model(subject)&.model_name&.singular&.then { |singular| "#{singular}_id" }
113
+ (reference.column == conventional) ? base : t("lethe.export.column_suffix", label: base, column: reference.column)
114
+ end
115
+
116
+ def action(policy)
117
+ text =
118
+ case policy.action
119
+ when :scrub then t("lethe.export.scrub", columns: list(policy.set.keys))
120
+ else t("lethe.export.#{policy.action}")
121
+ end
122
+ return text unless policy.where
123
+
124
+ conditions = policy.where.map { |column, value| "#{column} = #{Array(value).join(" / ")}" }
125
+ t("lethe.export.where", action: text, conditions: conditions.join(t("lethe.export.list_separator")))
126
+ end
127
+
128
+ def subject_action(policy)
129
+ return t("lethe.export.destroy") if policy.destroy
130
+
131
+ scrubbed = policy.scrub.keys
132
+ if scrubbed.empty?
133
+ t("lethe.export.soft_delete", column: policy.soft_delete)
134
+ else
135
+ t("lethe.export.soft_delete_scrub", column: policy.soft_delete, columns: list(scrubbed))
136
+ end
137
+ end
138
+
139
+ def retention(duration)
140
+ return "" unless duration
141
+
142
+ duration.parts.map { |unit, count| t("lethe.export.parts.#{unit}", count: count) }.join(" ")
143
+ end
144
+
145
+ def list(names)
146
+ names.join(t("lethe.export.list_separator"))
147
+ end
148
+
149
+ def row(cells)
150
+ "| #{cells.map { |cell| cell.to_s.gsub("|", "\\|").gsub(/\s*\n\s*/, " ") }.join(" | ")} |"
151
+ end
152
+
153
+ def relative(path)
154
+ Pathname(path).relative_path_from(@root).to_s
155
+ rescue ArgumentError
156
+ path.to_s
157
+ end
158
+
159
+ def t(key, **values)
160
+ I18n.t(key, **values, locale: @locale)
161
+ end
162
+ end
163
+ end