better_model 1.2.0 → 2.0.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 +4 -4
- data/README.md +211 -11
- data/lib/better_model/archivable.rb +3 -2
- data/lib/better_model/predicable.rb +73 -52
- data/lib/better_model/schedulable/occurrence_calculator.rb +1034 -0
- data/lib/better_model/schedulable/schedule_builder.rb +269 -0
- data/lib/better_model/schedulable.rb +356 -0
- data/lib/better_model/searchable.rb +4 -4
- data/lib/better_model/taggable.rb +466 -0
- data/lib/better_model/traceable.rb +123 -9
- data/lib/better_model/version.rb +1 -1
- data/lib/better_model/version_record.rb +6 -3
- data/lib/better_model.rb +6 -0
- data/lib/generators/better_model/taggable/taggable_generator.rb +129 -0
- data/lib/generators/better_model/taggable/templates/README.tt +62 -0
- data/lib/generators/better_model/taggable/templates/migration.rb.tt +21 -0
- metadata +9 -2
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Taggable - Sistema di gestione tag dichiarativo per modelli Rails
|
|
4
|
+
#
|
|
5
|
+
# Questo concern permette di gestire tag multipli sui modelli utilizzando array PostgreSQL
|
|
6
|
+
# con normalizzazione, validazione e statistiche. La ricerca è delegata a Predicable.
|
|
7
|
+
#
|
|
8
|
+
# Esempio di utilizzo:
|
|
9
|
+
# class Article < ApplicationRecord
|
|
10
|
+
# include BetterModel
|
|
11
|
+
#
|
|
12
|
+
# taggable do
|
|
13
|
+
# tag_field :tags
|
|
14
|
+
# normalize true
|
|
15
|
+
# validates_tags minimum: 1, maximum: 10
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Utilizzo:
|
|
20
|
+
# article.tag_with("ruby", "rails") # Aggiungi tag
|
|
21
|
+
# article.untag("rails") # Rimuovi tag
|
|
22
|
+
# article.tag_list = "ruby, rails, tutorial" # Da stringa CSV
|
|
23
|
+
# article.tagged_with?("ruby") # => true
|
|
24
|
+
#
|
|
25
|
+
# Ricerca (delegata a Predicable):
|
|
26
|
+
# Article.tags_contains("ruby") # Predicable
|
|
27
|
+
# Article.tags_overlaps(["ruby", "python"]) # Predicable
|
|
28
|
+
# Article.search(tags_contains: "ruby") # Searchable + Predicable
|
|
29
|
+
#
|
|
30
|
+
# Statistiche:
|
|
31
|
+
# Article.tag_counts # => {"ruby" => 45, "rails" => 38}
|
|
32
|
+
# Article.popular_tags(limit: 10) # => [["ruby", 45], ["rails", 38], ...]
|
|
33
|
+
#
|
|
34
|
+
module BetterModel
|
|
35
|
+
module Taggable
|
|
36
|
+
extend ActiveSupport::Concern
|
|
37
|
+
|
|
38
|
+
# Configurazione Taggable
|
|
39
|
+
class Configuration
|
|
40
|
+
attr_reader :validates_minimum, :validates_maximum, :allowed_tags, :forbidden_tags
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
@tag_field = :tags
|
|
44
|
+
@normalize = false
|
|
45
|
+
@strip = true
|
|
46
|
+
@min_length = nil
|
|
47
|
+
@max_length = nil
|
|
48
|
+
@delimiter = ","
|
|
49
|
+
@validates_minimum = nil
|
|
50
|
+
@validates_maximum = nil
|
|
51
|
+
@allowed_tags = nil
|
|
52
|
+
@forbidden_tags = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def tag_field(field_name = nil)
|
|
56
|
+
return @tag_field if field_name.nil?
|
|
57
|
+
@tag_field = field_name.to_sym
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize(value = nil)
|
|
61
|
+
return @normalize if value.nil?
|
|
62
|
+
@normalize = value
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def strip(value = nil)
|
|
66
|
+
return @strip if value.nil?
|
|
67
|
+
@strip = value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def min_length(value = nil)
|
|
71
|
+
return @min_length if value.nil?
|
|
72
|
+
@min_length = value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def max_length(value = nil)
|
|
76
|
+
return @max_length if value.nil?
|
|
77
|
+
@max_length = value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def delimiter(value = nil)
|
|
81
|
+
return @delimiter if value.nil?
|
|
82
|
+
@delimiter = value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validates_tags(options = {})
|
|
86
|
+
@validates_minimum = options[:minimum]
|
|
87
|
+
@validates_maximum = options[:maximum]
|
|
88
|
+
@allowed_tags = Array(options[:allowed_tags]) if options[:allowed_tags]
|
|
89
|
+
@forbidden_tags = Array(options[:forbidden_tags]) if options[:forbidden_tags]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
included do
|
|
94
|
+
# Valida che sia incluso solo in modelli ActiveRecord
|
|
95
|
+
unless ancestors.include?(ActiveRecord::Base)
|
|
96
|
+
raise ArgumentError, "BetterModel::Taggable can only be included in ActiveRecord models"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Configurazione Taggable per questa classe
|
|
100
|
+
class_attribute :taggable_config, default: nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
class_methods do
|
|
104
|
+
# DSL per configurare Taggable
|
|
105
|
+
#
|
|
106
|
+
# Esempio:
|
|
107
|
+
# taggable do
|
|
108
|
+
# tag_field :tags
|
|
109
|
+
# normalize true
|
|
110
|
+
# strip true
|
|
111
|
+
# min_length 2
|
|
112
|
+
# max_length 50
|
|
113
|
+
# delimiter ','
|
|
114
|
+
# validates_tags minimum: 1, maximum: 10, allowed_tags: ["ruby", "rails"]
|
|
115
|
+
# end
|
|
116
|
+
def taggable(&block)
|
|
117
|
+
# Previeni configurazione multipla
|
|
118
|
+
if taggable_config.present?
|
|
119
|
+
raise ArgumentError, "Taggable already configured for #{name}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Crea configurazione
|
|
123
|
+
config = Configuration.new
|
|
124
|
+
config.instance_eval(&block) if block_given?
|
|
125
|
+
|
|
126
|
+
# Valida che il campo esista
|
|
127
|
+
tag_field_name = config.tag_field.to_s
|
|
128
|
+
unless column_names.include?(tag_field_name)
|
|
129
|
+
raise ArgumentError, "Tag field #{config.tag_field} does not exist in #{table_name}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Salva configurazione (frozen per thread-safety)
|
|
133
|
+
self.taggable_config = config.freeze
|
|
134
|
+
|
|
135
|
+
# Auto-registra predicates per ricerca (delegato a Predicable)
|
|
136
|
+
predicates config.tag_field if respond_to?(:predicates)
|
|
137
|
+
|
|
138
|
+
# Registra validazioni se configurate
|
|
139
|
+
setup_validations(config) if config.validates_minimum || config.validates_maximum ||
|
|
140
|
+
config.allowed_tags || config.forbidden_tags
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# ============================================================================
|
|
144
|
+
# CLASS METHODS - Statistiche
|
|
145
|
+
# ============================================================================
|
|
146
|
+
|
|
147
|
+
# Restituisce un hash con il conteggio di ciascun tag
|
|
148
|
+
#
|
|
149
|
+
# Esempio:
|
|
150
|
+
# Article.tag_counts # => {"ruby" => 45, "rails" => 38, "tutorial" => 12}
|
|
151
|
+
def tag_counts
|
|
152
|
+
return {} unless taggable_config
|
|
153
|
+
|
|
154
|
+
field = taggable_config.tag_field
|
|
155
|
+
counts = Hash.new(0)
|
|
156
|
+
|
|
157
|
+
# Itera tutti i record e conta i tag
|
|
158
|
+
find_each do |record|
|
|
159
|
+
tags = record.public_send(field) || []
|
|
160
|
+
tags.each { |tag| counts[tag] += 1 }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
counts
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Restituisce i tag più popolari con il loro conteggio
|
|
167
|
+
#
|
|
168
|
+
# Esempio:
|
|
169
|
+
# Article.popular_tags(limit: 10)
|
|
170
|
+
# # => [["ruby", 45], ["rails", 38], ["tutorial", 12]]
|
|
171
|
+
def popular_tags(limit: 10)
|
|
172
|
+
return [] unless taggable_config
|
|
173
|
+
|
|
174
|
+
tag_counts
|
|
175
|
+
.sort_by { |_tag, count| -count }
|
|
176
|
+
.first(limit)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Restituisce i tag che appaiono insieme al tag specificato
|
|
180
|
+
#
|
|
181
|
+
# Esempio:
|
|
182
|
+
# Article.related_tags("ruby", limit: 10)
|
|
183
|
+
# # => ["rails", "gem", "activerecord"]
|
|
184
|
+
def related_tags(tag, limit: 10)
|
|
185
|
+
return [] unless taggable_config
|
|
186
|
+
|
|
187
|
+
field = taggable_config.tag_field
|
|
188
|
+
related_counts = Hash.new(0)
|
|
189
|
+
|
|
190
|
+
# Normalizza il tag query
|
|
191
|
+
config = taggable_config
|
|
192
|
+
normalized_tag = tag.to_s
|
|
193
|
+
normalized_tag = normalized_tag.strip if config.strip
|
|
194
|
+
normalized_tag = normalized_tag.downcase if config.normalize
|
|
195
|
+
|
|
196
|
+
# Trova record che contengono il tag
|
|
197
|
+
find_each do |record|
|
|
198
|
+
tags = record.public_send(field) || []
|
|
199
|
+
next unless tags.include?(normalized_tag)
|
|
200
|
+
|
|
201
|
+
# Conta gli altri tag che appaiono insieme
|
|
202
|
+
tags.each do |other_tag|
|
|
203
|
+
next if other_tag == normalized_tag
|
|
204
|
+
related_counts[other_tag] += 1
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Restituisci ordinati per frequenza
|
|
209
|
+
related_counts
|
|
210
|
+
.sort_by { |_tag, count| -count }
|
|
211
|
+
.first(limit)
|
|
212
|
+
.map(&:first)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
# Setup delle validazioni ActiveRecord
|
|
218
|
+
def setup_validations(config)
|
|
219
|
+
field = config.tag_field
|
|
220
|
+
|
|
221
|
+
# Validazione minimum
|
|
222
|
+
if config.validates_minimum
|
|
223
|
+
min = config.validates_minimum
|
|
224
|
+
validate do
|
|
225
|
+
tags = public_send(field) || []
|
|
226
|
+
if tags.size < min
|
|
227
|
+
errors.add(field, "must have at least #{min} tags")
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Validazione maximum
|
|
233
|
+
if config.validates_maximum
|
|
234
|
+
max = config.validates_maximum
|
|
235
|
+
validate do
|
|
236
|
+
tags = public_send(field) || []
|
|
237
|
+
if tags.size > max
|
|
238
|
+
errors.add(field, "must have at most #{max} tags")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Validazione whitelist
|
|
244
|
+
if config.allowed_tags
|
|
245
|
+
allowed = config.allowed_tags
|
|
246
|
+
validate do
|
|
247
|
+
tags = public_send(field) || []
|
|
248
|
+
invalid_tags = tags - allowed
|
|
249
|
+
if invalid_tags.any?
|
|
250
|
+
errors.add(field, "contains invalid tags: #{invalid_tags.join(', ')}")
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Validazione blacklist
|
|
256
|
+
if config.forbidden_tags
|
|
257
|
+
forbidden = config.forbidden_tags
|
|
258
|
+
validate do
|
|
259
|
+
tags = public_send(field) || []
|
|
260
|
+
forbidden_found = tags & forbidden
|
|
261
|
+
if forbidden_found.any?
|
|
262
|
+
errors.add(field, "contains forbidden tags: #{forbidden_found.join(', ')}")
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# ============================================================================
|
|
270
|
+
# INSTANCE METHODS - Gestione Tag
|
|
271
|
+
# ============================================================================
|
|
272
|
+
|
|
273
|
+
# Aggiunge uno o più tag al record
|
|
274
|
+
#
|
|
275
|
+
# Esempio:
|
|
276
|
+
# article.tag_with("ruby")
|
|
277
|
+
# article.tag_with("ruby", "rails", "tutorial")
|
|
278
|
+
def tag_with(*new_tags)
|
|
279
|
+
return unless taggable_enabled?
|
|
280
|
+
|
|
281
|
+
config = self.class.taggable_config
|
|
282
|
+
field = config.tag_field
|
|
283
|
+
|
|
284
|
+
# Inizializza array se nil
|
|
285
|
+
current_tags = public_send(field) || []
|
|
286
|
+
|
|
287
|
+
# Normalizza e aggiungi tag (evita duplicati con |)
|
|
288
|
+
normalized_tags = new_tags.flatten.map { |tag| normalize_tag(tag) }.compact
|
|
289
|
+
updated_tags = (current_tags | normalized_tags)
|
|
290
|
+
|
|
291
|
+
# Aggiorna il campo
|
|
292
|
+
public_send("#{field}=", updated_tags)
|
|
293
|
+
save if persisted?
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Rimuove uno o più tag dal record
|
|
297
|
+
#
|
|
298
|
+
# Esempio:
|
|
299
|
+
# article.untag("tutorial")
|
|
300
|
+
# article.untag("ruby", "rails")
|
|
301
|
+
def untag(*tags_to_remove)
|
|
302
|
+
return unless taggable_enabled?
|
|
303
|
+
|
|
304
|
+
config = self.class.taggable_config
|
|
305
|
+
field = config.tag_field
|
|
306
|
+
|
|
307
|
+
# Ottieni tag attuali
|
|
308
|
+
current_tags = public_send(field) || []
|
|
309
|
+
|
|
310
|
+
# Normalizza tag da rimuovere
|
|
311
|
+
normalized_tags = tags_to_remove.flatten.map { |tag| normalize_tag(tag) }.compact
|
|
312
|
+
|
|
313
|
+
# Rimuovi tag
|
|
314
|
+
updated_tags = current_tags - normalized_tags
|
|
315
|
+
|
|
316
|
+
# Aggiorna il campo
|
|
317
|
+
public_send("#{field}=", updated_tags)
|
|
318
|
+
save if persisted?
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Sostituisce tutti i tag esistenti con nuovi tag
|
|
322
|
+
#
|
|
323
|
+
# Esempio:
|
|
324
|
+
# article.retag("python", "django")
|
|
325
|
+
def retag(*new_tags)
|
|
326
|
+
return unless taggable_enabled?
|
|
327
|
+
|
|
328
|
+
config = self.class.taggable_config
|
|
329
|
+
field = config.tag_field
|
|
330
|
+
|
|
331
|
+
# Normalizza nuovi tag
|
|
332
|
+
normalized_tags = new_tags.flatten.map { |tag| normalize_tag(tag) }.compact.uniq
|
|
333
|
+
|
|
334
|
+
# Sostituisci tutti i tag
|
|
335
|
+
public_send("#{field}=", normalized_tags)
|
|
336
|
+
save if persisted?
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Verifica se il record ha un determinato tag
|
|
340
|
+
#
|
|
341
|
+
# Esempio:
|
|
342
|
+
# article.tagged_with?("ruby") # => true/false
|
|
343
|
+
def tagged_with?(tag)
|
|
344
|
+
return false unless taggable_enabled?
|
|
345
|
+
|
|
346
|
+
config = self.class.taggable_config
|
|
347
|
+
field = config.tag_field
|
|
348
|
+
|
|
349
|
+
current_tags = public_send(field) || []
|
|
350
|
+
normalized_tag = normalize_tag(tag)
|
|
351
|
+
|
|
352
|
+
current_tags.include?(normalized_tag)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# ============================================================================
|
|
356
|
+
# TAG LIST (CSV Interface)
|
|
357
|
+
# ============================================================================
|
|
358
|
+
|
|
359
|
+
# Restituisce i tag come stringa separata da delimitatore
|
|
360
|
+
#
|
|
361
|
+
# Esempio:
|
|
362
|
+
# article.tag_list # => "ruby, rails, tutorial"
|
|
363
|
+
def tag_list
|
|
364
|
+
return "" unless taggable_enabled?
|
|
365
|
+
|
|
366
|
+
config = self.class.taggable_config
|
|
367
|
+
field = config.tag_field
|
|
368
|
+
delimiter = config.delimiter
|
|
369
|
+
|
|
370
|
+
current_tags = public_send(field) || []
|
|
371
|
+
|
|
372
|
+
# Aggiungi spazio dopo virgola per leggibilità (solo se delimiter è virgola)
|
|
373
|
+
separator = delimiter == "," ? "#{delimiter} " : delimiter
|
|
374
|
+
current_tags.join(separator)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Imposta i tag da una stringa separata da delimitatore
|
|
378
|
+
#
|
|
379
|
+
# Esempio:
|
|
380
|
+
# article.tag_list = "ruby, rails, tutorial"
|
|
381
|
+
def tag_list=(tag_string)
|
|
382
|
+
return unless taggable_enabled?
|
|
383
|
+
|
|
384
|
+
config = self.class.taggable_config
|
|
385
|
+
field = config.tag_field
|
|
386
|
+
delimiter = config.delimiter
|
|
387
|
+
|
|
388
|
+
# Parse string
|
|
389
|
+
if tag_string.blank?
|
|
390
|
+
tags = []
|
|
391
|
+
else
|
|
392
|
+
tags = tag_string.split(delimiter).map { |tag| normalize_tag(tag) }.compact.uniq
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Imposta tags
|
|
396
|
+
public_send("#{field}=", tags)
|
|
397
|
+
save if persisted?
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# ============================================================================
|
|
401
|
+
# JSON SERIALIZATION
|
|
402
|
+
# ============================================================================
|
|
403
|
+
|
|
404
|
+
# Override as_json per includere informazioni tag
|
|
405
|
+
#
|
|
406
|
+
# Opzioni:
|
|
407
|
+
# include_tag_list: true # Includi tag_list come string
|
|
408
|
+
# include_tag_stats: true # Includi statistiche tag
|
|
409
|
+
#
|
|
410
|
+
# Esempio:
|
|
411
|
+
# article.as_json(include_tag_list: true, include_tag_stats: true)
|
|
412
|
+
def as_json(options = {})
|
|
413
|
+
json = super(options)
|
|
414
|
+
|
|
415
|
+
return json unless taggable_enabled?
|
|
416
|
+
|
|
417
|
+
# Aggiungi tag_list se richiesto
|
|
418
|
+
if options[:include_tag_list]
|
|
419
|
+
json["tag_list"] = tag_list
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Aggiungi statistiche tag se richiesto
|
|
423
|
+
if options[:include_tag_stats]
|
|
424
|
+
config = self.class.taggable_config
|
|
425
|
+
field = config.tag_field
|
|
426
|
+
tags = public_send(field) || []
|
|
427
|
+
|
|
428
|
+
json["tag_stats"] = {
|
|
429
|
+
"count" => tags.size,
|
|
430
|
+
"tags" => tags
|
|
431
|
+
}
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
json
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
private
|
|
438
|
+
|
|
439
|
+
# Verifica se Taggable è abilitato per questa classe
|
|
440
|
+
def taggable_enabled?
|
|
441
|
+
self.class.taggable_config.present?
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Normalizza un tag secondo la configurazione
|
|
445
|
+
def normalize_tag(tag)
|
|
446
|
+
return nil if tag.blank?
|
|
447
|
+
|
|
448
|
+
config = self.class.taggable_config
|
|
449
|
+
normalized = tag.to_s
|
|
450
|
+
|
|
451
|
+
# Strip whitespace
|
|
452
|
+
normalized = normalized.strip if config.strip
|
|
453
|
+
|
|
454
|
+
# Lowercase
|
|
455
|
+
normalized = normalized.downcase if config.normalize
|
|
456
|
+
|
|
457
|
+
# Min length
|
|
458
|
+
return nil if config.min_length && normalized.length < config.min_length
|
|
459
|
+
|
|
460
|
+
# Max length
|
|
461
|
+
normalized = normalized[0...config.max_length] if config.max_length && normalized.length > config.max_length
|
|
462
|
+
|
|
463
|
+
normalized
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
@@ -66,6 +66,7 @@ module BetterModel
|
|
|
66
66
|
class_attribute :traceable_enabled, default: false
|
|
67
67
|
class_attribute :traceable_config, default: {}.freeze
|
|
68
68
|
class_attribute :traceable_fields, default: [].freeze
|
|
69
|
+
class_attribute :traceable_sensitive_fields, default: {}.freeze
|
|
69
70
|
class_attribute :traceable_table_name, default: nil
|
|
70
71
|
class_attribute :_traceable_setup_done, default: false
|
|
71
72
|
end
|
|
@@ -88,6 +89,7 @@ module BetterModel
|
|
|
88
89
|
configurator.instance_eval(&block)
|
|
89
90
|
self.traceable_config = configurator.to_h.freeze
|
|
90
91
|
self.traceable_fields = configurator.fields.freeze
|
|
92
|
+
self.traceable_sensitive_fields = configurator.sensitive_fields.freeze
|
|
91
93
|
self.traceable_table_name = configurator.table_name
|
|
92
94
|
end
|
|
93
95
|
|
|
@@ -282,7 +284,7 @@ module BetterModel
|
|
|
282
284
|
# @param updated_by_id [Integer] User ID performing rollback
|
|
283
285
|
# @param updated_reason [String] Reason for rollback
|
|
284
286
|
# @return [self]
|
|
285
|
-
def rollback_to(version, updated_by_id: nil, updated_reason: nil)
|
|
287
|
+
def rollback_to(version, updated_by_id: nil, updated_reason: nil, allow_sensitive: false)
|
|
286
288
|
raise NotEnabledError unless self.class.traceable_enabled?
|
|
287
289
|
|
|
288
290
|
version = versions.find(version) if version.is_a?(Integer)
|
|
@@ -292,8 +294,23 @@ module BetterModel
|
|
|
292
294
|
|
|
293
295
|
# Apply changes from version
|
|
294
296
|
if version.object_changes
|
|
295
|
-
version.object_changes.each do |field, (before_value,
|
|
296
|
-
|
|
297
|
+
version.object_changes.each do |field, (before_value, after_value)|
|
|
298
|
+
field_sym = field.to_sym
|
|
299
|
+
|
|
300
|
+
# Check if field is sensitive
|
|
301
|
+
if self.class.traceable_sensitive_fields.key?(field_sym)
|
|
302
|
+
unless allow_sensitive
|
|
303
|
+
Rails.logger.warn "[BetterModel::Traceable] Skipping sensitive field '#{field}' in rollback. Use allow_sensitive: true to rollback sensitive fields."
|
|
304
|
+
next
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
Rails.logger.warn "[BetterModel::Traceable] Rolling back sensitive field '#{field}' - allowed by allow_sensitive flag"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# For 'created' events, use after_value (the value at creation)
|
|
311
|
+
# For 'updated' events, use before_value (the value before the update)
|
|
312
|
+
rollback_value = version.event == "created" ? after_value : before_value
|
|
313
|
+
send("#{field}=", rollback_value) if respond_to?("#{field}=")
|
|
297
314
|
end
|
|
298
315
|
end
|
|
299
316
|
|
|
@@ -350,20 +367,104 @@ module BetterModel
|
|
|
350
367
|
def tracked_changes
|
|
351
368
|
return {} if self.class.traceable_fields.empty?
|
|
352
369
|
|
|
353
|
-
if saved_changes.any?
|
|
370
|
+
raw_changes = if saved_changes.any?
|
|
354
371
|
# After save: use saved_changes
|
|
355
372
|
saved_changes.slice(*self.class.traceable_fields.map(&:to_s))
|
|
356
373
|
else
|
|
357
374
|
# Before save: use changes
|
|
358
375
|
changes.slice(*self.class.traceable_fields.map(&:to_s))
|
|
359
376
|
end
|
|
377
|
+
|
|
378
|
+
# Apply redaction to sensitive fields
|
|
379
|
+
apply_redaction_to_changes(raw_changes)
|
|
360
380
|
end
|
|
361
381
|
|
|
362
382
|
# Get final state for destroyed records
|
|
363
383
|
def tracked_final_state
|
|
364
|
-
self.class.traceable_fields.each_with_object({}) do |field, hash|
|
|
384
|
+
raw_state = self.class.traceable_fields.each_with_object({}) do |field, hash|
|
|
365
385
|
hash[field.to_s] = [ send(field), nil ]
|
|
366
386
|
end
|
|
387
|
+
|
|
388
|
+
# Apply redaction to sensitive fields
|
|
389
|
+
apply_redaction_to_changes(raw_state)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Apply redaction to changes hash based on sensitive field configuration
|
|
393
|
+
#
|
|
394
|
+
# @param changes_hash [Hash] Hash of field changes {field => [old, new]}
|
|
395
|
+
# @return [Hash] Redacted changes hash
|
|
396
|
+
def apply_redaction_to_changes(changes_hash)
|
|
397
|
+
return changes_hash if self.class.traceable_sensitive_fields.empty?
|
|
398
|
+
|
|
399
|
+
changes_hash.each_with_object({}) do |(field, values), result|
|
|
400
|
+
field_sym = field.to_sym
|
|
401
|
+
|
|
402
|
+
if self.class.traceable_sensitive_fields.key?(field_sym)
|
|
403
|
+
level = self.class.traceable_sensitive_fields[field_sym]
|
|
404
|
+
result[field] = [
|
|
405
|
+
redact_value(field_sym, values[0], level),
|
|
406
|
+
redact_value(field_sym, values[1], level)
|
|
407
|
+
]
|
|
408
|
+
else
|
|
409
|
+
result[field] = values
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Redact a single value based on sensitivity level
|
|
415
|
+
#
|
|
416
|
+
# @param field [Symbol] Field name
|
|
417
|
+
# @param value [Object] Value to redact
|
|
418
|
+
# @param level [Symbol] Sensitivity level (:full, :partial, :hash)
|
|
419
|
+
# @return [String] Redacted value
|
|
420
|
+
def redact_value(field, value, level)
|
|
421
|
+
return "[REDACTED]" if value.nil? && level == :full
|
|
422
|
+
|
|
423
|
+
case level
|
|
424
|
+
when :full
|
|
425
|
+
"[REDACTED]"
|
|
426
|
+
when :partial
|
|
427
|
+
redact_partial(field, value)
|
|
428
|
+
when :hash
|
|
429
|
+
require "digest"
|
|
430
|
+
"sha256:#{Digest::SHA256.hexdigest(value.to_s)}"
|
|
431
|
+
else
|
|
432
|
+
value # Fallback to original value
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Partially redact a value based on field patterns
|
|
437
|
+
#
|
|
438
|
+
# @param field [Symbol] Field name
|
|
439
|
+
# @param value [Object] Value to partially redact
|
|
440
|
+
# @return [String] Partially redacted value
|
|
441
|
+
def redact_partial(field, value)
|
|
442
|
+
return "[REDACTED]" if value.blank?
|
|
443
|
+
|
|
444
|
+
str = value.to_s
|
|
445
|
+
|
|
446
|
+
# Credit card pattern (13-19 digits)
|
|
447
|
+
if str.gsub(/\D/, "").match?(/^\d{13,19}$/)
|
|
448
|
+
digits = str.gsub(/\D/, "")
|
|
449
|
+
"****#{digits[-4..-1]}"
|
|
450
|
+
# Email pattern
|
|
451
|
+
elsif str.include?("@")
|
|
452
|
+
parts = str.split("@")
|
|
453
|
+
username_length = parts.first.length
|
|
454
|
+
masked_username = username_length <= 3 ? "***" : "#{parts.first[0]}***"
|
|
455
|
+
"#{masked_username}@#{parts.last}"
|
|
456
|
+
# SSN pattern (US: XXX-XX-XXXX or XXXXXXXXX)
|
|
457
|
+
elsif str.gsub(/\D/, "").match?(/^\d{9}$/)
|
|
458
|
+
digits = str.gsub(/\D/, "")
|
|
459
|
+
"***-**-#{digits[-4..-1]}"
|
|
460
|
+
# Phone pattern (10+ digits)
|
|
461
|
+
elsif str.gsub(/\D/, "").match?(/^\d{10,}$/)
|
|
462
|
+
digits = str.gsub(/\D/, "")
|
|
463
|
+
"***-***-#{digits[-4..-1]}"
|
|
464
|
+
# Default: show only length
|
|
465
|
+
else
|
|
466
|
+
"[REDACTED:#{str.length}chars]"
|
|
467
|
+
end
|
|
367
468
|
end
|
|
368
469
|
|
|
369
470
|
# Create a version record
|
|
@@ -388,19 +489,32 @@ module BetterModel
|
|
|
388
489
|
|
|
389
490
|
# Configurator per traceable DSL
|
|
390
491
|
class TraceableConfigurator
|
|
391
|
-
attr_reader :fields, :table_name
|
|
492
|
+
attr_reader :fields, :table_name, :sensitive_fields
|
|
392
493
|
|
|
393
494
|
def initialize(model_class)
|
|
394
495
|
@model_class = model_class
|
|
395
496
|
@fields = []
|
|
497
|
+
@sensitive_fields = {}
|
|
396
498
|
@table_name = nil
|
|
397
499
|
end
|
|
398
500
|
|
|
399
501
|
# Specify which fields to track
|
|
400
502
|
#
|
|
401
503
|
# @param field_names [Array<Symbol>] Field names to track
|
|
402
|
-
|
|
403
|
-
|
|
504
|
+
# @param sensitive [Symbol, nil] Sensitivity level (:full, :partial, :hash)
|
|
505
|
+
#
|
|
506
|
+
# @example Normal tracking
|
|
507
|
+
# track :title, :status
|
|
508
|
+
#
|
|
509
|
+
# @example Sensitive tracking
|
|
510
|
+
# track :password, sensitive: :full
|
|
511
|
+
# track :email, sensitive: :partial
|
|
512
|
+
# track :ssn, sensitive: :hash
|
|
513
|
+
def track(*field_names, sensitive: nil)
|
|
514
|
+
field_names.each do |field|
|
|
515
|
+
@fields << field
|
|
516
|
+
@sensitive_fields[field] = sensitive if sensitive
|
|
517
|
+
end
|
|
404
518
|
end
|
|
405
519
|
|
|
406
520
|
# Specify custom table name for versions
|
|
@@ -411,7 +525,7 @@ module BetterModel
|
|
|
411
525
|
end
|
|
412
526
|
|
|
413
527
|
def to_h
|
|
414
|
-
{ fields: @fields, table_name: @table_name }
|
|
528
|
+
{ fields: @fields, sensitive_fields: @sensitive_fields, table_name: @table_name }
|
|
415
529
|
end
|
|
416
530
|
end
|
|
417
531
|
|
data/lib/better_model/version.rb
CHANGED
|
@@ -45,11 +45,14 @@ module BetterModel
|
|
|
45
45
|
}
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# Check if a specific field changed
|
|
48
|
+
# Check if a specific field changed in this version
|
|
49
|
+
# This method overrides ActiveRecord's changed? to accept a field_name parameter
|
|
49
50
|
#
|
|
50
|
-
# @param field_name [Symbol, String] Field name
|
|
51
|
+
# @param field_name [Symbol, String, nil] Field name (if nil, calls ActiveRecord's changed?)
|
|
51
52
|
# @return [Boolean]
|
|
52
|
-
def changed?(field_name)
|
|
53
|
+
def changed?(field_name = nil)
|
|
54
|
+
return super() if field_name.nil?
|
|
55
|
+
|
|
53
56
|
object_changes&.key?(field_name.to_s) || false
|
|
54
57
|
end
|
|
55
58
|
|
data/lib/better_model.rb
CHANGED
|
@@ -18,6 +18,10 @@ require "better_model/stateable/configurator"
|
|
|
18
18
|
require "better_model/stateable/errors"
|
|
19
19
|
require "better_model/stateable/guard"
|
|
20
20
|
require "better_model/stateable/transition"
|
|
21
|
+
require "better_model/taggable"
|
|
22
|
+
require "better_model/schedulable"
|
|
23
|
+
require "better_model/schedulable/schedule_builder"
|
|
24
|
+
require "better_model/schedulable/occurrence_calculator"
|
|
21
25
|
|
|
22
26
|
module BetterModel
|
|
23
27
|
extend ActiveSupport::Concern
|
|
@@ -33,5 +37,7 @@ module BetterModel
|
|
|
33
37
|
include BetterModel::Traceable
|
|
34
38
|
include BetterModel::Validatable
|
|
35
39
|
include BetterModel::Stateable
|
|
40
|
+
include BetterModel::Taggable
|
|
41
|
+
include BetterModel::Schedulable
|
|
36
42
|
end
|
|
37
43
|
end
|