dscf-core 0.2.6 → 0.2.7
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 26fe3945c60e59f5abf2ccff51c02da617f1c063cf19c903c5505567ceed13ca
|
|
4
|
+
data.tar.gz: 57d0f65ee3a45cb0a8e5560fa90cf99ac1ec6f09ef7c161900eb3357c945ac89
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a75424cbfffeca3f5205660320c9a6cd8a424680d76368f611feef1e578e7b55f90bcc6a0b44eec22d3d09a0c9621f0eb3fffbb149cc64965fa47a2fefea06a6
|
|
7
|
+
data.tar.gz: 393d2aac7b201c4e84e66fecba0e54ec510977f5b4996bd9b0371e458f3a991408e9cd0ef6f339421fca5d96b303f4a9bee304b93818ad88ac637fdcf9b3c00e
|
|
@@ -16,14 +16,28 @@ module Dscf
|
|
|
16
16
|
|
|
17
17
|
class_methods do
|
|
18
18
|
# DSL: Define what to audit
|
|
19
|
-
#
|
|
20
|
-
#
|
|
19
|
+
#
|
|
20
|
+
# Simple format (backwards compatible):
|
|
21
|
+
# auditable associated: [:reviews], only: [:status], on: [:approve, :reject]
|
|
22
|
+
# auditable only: [:name, :email], on: [:create, :update]
|
|
23
|
+
#
|
|
24
|
+
# Advanced format with per-association filters:
|
|
25
|
+
# auditable on: [:create, :update],
|
|
26
|
+
# only: [:status, :approved_at], # filter for main record
|
|
27
|
+
# except: [:internal_notes], # exclude for main record
|
|
28
|
+
# associated: {
|
|
29
|
+
# reviews: { only: [:status, :approved_at] }, # filter for reviews
|
|
30
|
+
# comments: { except: [:private_flag] } # exclude for comments
|
|
31
|
+
# }
|
|
21
32
|
def auditable(associated: [], only: [], except: [], on: [], action: nil, transactional: true)
|
|
22
33
|
# Validate configuration
|
|
23
34
|
validate_audit_config!(associated: associated, only: only, except: except, on: on, action: action)
|
|
24
35
|
|
|
36
|
+
# Normalize associated to always be a hash internally
|
|
37
|
+
normalized_associated = normalize_associated_for_config(associated)
|
|
38
|
+
|
|
25
39
|
config = {
|
|
26
|
-
associated:
|
|
40
|
+
associated: normalized_associated,
|
|
27
41
|
only: Array(only),
|
|
28
42
|
except: Array(except),
|
|
29
43
|
on: Array(on).map(&:to_sym),
|
|
@@ -33,16 +47,56 @@ module Dscf
|
|
|
33
47
|
self._audit_configs += [config]
|
|
34
48
|
end
|
|
35
49
|
|
|
50
|
+
# Normalize associated configuration to hash format
|
|
51
|
+
# Input: [:reviews, :comments] or { reviews: { only: [:status] }, comments: {} }
|
|
52
|
+
# Output: { reviews: { only: [...], except: [...] }, comments: { only: nil, except: nil } }
|
|
53
|
+
private def normalize_associated_for_config(associated)
|
|
54
|
+
return {} if associated.blank?
|
|
55
|
+
|
|
56
|
+
if associated.is_a?(Array)
|
|
57
|
+
# Old format: [:reviews, :comments] -> convert to hash
|
|
58
|
+
associated.each_with_object({}) { |name, h| h[name] = {} }
|
|
59
|
+
elsif associated.is_a?(Hash)
|
|
60
|
+
# New format: already a hash
|
|
61
|
+
associated
|
|
62
|
+
else
|
|
63
|
+
{}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
36
67
|
# Validate audit configuration at definition time
|
|
37
68
|
def validate_audit_config!(associated:, only:, except:, on:, action:)
|
|
38
|
-
# Validate associated
|
|
39
|
-
Array
|
|
40
|
-
|
|
41
|
-
|
|
69
|
+
# Validate associated format
|
|
70
|
+
if associated.is_a?(Array)
|
|
71
|
+
Array(associated).each do |assoc|
|
|
72
|
+
unless assoc.is_a?(Symbol)
|
|
73
|
+
raise ArgumentError, "auditable associated: must be an array of symbols, got #{assoc.class} for #{assoc.inspect}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
elsif associated.is_a?(Hash)
|
|
77
|
+
associated.each do |assoc_name, assoc_config|
|
|
78
|
+
unless assoc_name.is_a?(Symbol)
|
|
79
|
+
raise ArgumentError, "auditable associated: hash keys must be symbols, got #{assoc_name.class}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if assoc_config.present? && !assoc_config.is_a?(Hash)
|
|
83
|
+
raise ArgumentError, "auditable associated: hash values must be hashes with :only/:except, got #{assoc_config.class}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Validate only/except in association config
|
|
87
|
+
next unless assoc_config.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
assoc_only = Array(assoc_config[:only])
|
|
90
|
+
assoc_except = Array(assoc_config[:except])
|
|
91
|
+
overlap = assoc_only & assoc_except
|
|
92
|
+
if overlap.any?
|
|
93
|
+
Rails.logger.warn "⚠️ Auditable config warning: 'only' and 'except' overlap for #{assoc_name}: " \
|
|
94
|
+
"#{overlap.join(', ')}. 'except' will take precedence."
|
|
95
|
+
end
|
|
42
96
|
end
|
|
43
97
|
end
|
|
44
98
|
|
|
45
|
-
# Validate only/except don't overlap
|
|
99
|
+
# Validate only/except don't overlap for main record
|
|
46
100
|
only_arr = Array(only).map(&:to_sym)
|
|
47
101
|
except_arr = Array(except).map(&:to_sym)
|
|
48
102
|
overlap = only_arr & except_arr
|
|
@@ -147,7 +201,9 @@ module Dscf
|
|
|
147
201
|
# Capture current state of associations
|
|
148
202
|
@audit_snapshot[:before_associated] = {}
|
|
149
203
|
active_configs.each do |config|
|
|
150
|
-
|
|
204
|
+
# Handle both array and hash formats for associated
|
|
205
|
+
assoc_names = config[:associated].is_a?(Hash) ? config[:associated].keys : config[:associated]
|
|
206
|
+
assoc_names.each do |assoc_name|
|
|
151
207
|
next unless record.respond_to?(assoc_name)
|
|
152
208
|
|
|
153
209
|
begin
|
|
@@ -211,7 +267,9 @@ module Dscf
|
|
|
211
267
|
after_associated = {}
|
|
212
268
|
|
|
213
269
|
active_configs.each do |config|
|
|
214
|
-
|
|
270
|
+
# Handle both array and hash formats for associated
|
|
271
|
+
assoc_names = config[:associated].is_a?(Hash) ? config[:associated].keys : config[:associated]
|
|
272
|
+
assoc_names.each do |assoc_name|
|
|
215
273
|
next unless record.respond_to?(assoc_name)
|
|
216
274
|
|
|
217
275
|
begin
|
|
@@ -78,7 +78,7 @@ module Dscf
|
|
|
78
78
|
status: :bad_request
|
|
79
79
|
)
|
|
80
80
|
end
|
|
81
|
-
perform_review_action(**action_config.slice(:status, :require_feedback, :after, :update_model))
|
|
81
|
+
perform_review_action(**action_config.slice(:status, :require_feedback, :after, :update_model, :from))
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
|
|
@@ -88,7 +88,7 @@ module Dscf
|
|
|
88
88
|
approve: {status: "approved"},
|
|
89
89
|
reject: {status: "rejected", require_feedback: true},
|
|
90
90
|
request_modification: {status: "modify", require_feedback: true},
|
|
91
|
-
resubmit: {status: "pending", update_model: true}
|
|
91
|
+
resubmit: {status: "pending", update_model: true, from: "modify"}
|
|
92
92
|
}
|
|
93
93
|
end
|
|
94
94
|
|
|
@@ -109,10 +109,22 @@ module Dscf
|
|
|
109
109
|
|
|
110
110
|
def validate_action_config(actions, statuses, context_name)
|
|
111
111
|
actions.each do |action_name, opts|
|
|
112
|
-
|
|
112
|
+
unless statuses.include?(opts[:status])
|
|
113
|
+
raise ArgumentError, <<~MSG
|
|
114
|
+
Action '#{action_name}' in context '#{context_name}' maps to invalid status '#{opts[:status]}'. Must be one of: #{statuses.join(', ')}
|
|
115
|
+
MSG
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validate 'from' constraints if present
|
|
119
|
+
next unless opts[:from].present?
|
|
120
|
+
|
|
121
|
+
from_statuses = Array(opts[:from]).map(&:to_s)
|
|
122
|
+
invalid_statuses = from_statuses - statuses
|
|
123
|
+
|
|
124
|
+
next if invalid_statuses.empty?
|
|
113
125
|
|
|
114
126
|
raise ArgumentError, <<~MSG
|
|
115
|
-
Action '#{action_name}' in context '#{context_name}'
|
|
127
|
+
Action '#{action_name}' in context '#{context_name}' has invalid 'from' statuses: #{invalid_statuses.join(', ')}. Must be one of: #{statuses.join(', ')}
|
|
116
128
|
MSG
|
|
117
129
|
end
|
|
118
130
|
end
|
|
@@ -146,7 +158,8 @@ module Dscf
|
|
|
146
158
|
self.class.review_action_names.include?(action_name.to_sym)
|
|
147
159
|
end
|
|
148
160
|
|
|
149
|
-
def perform_review_action(status:, require_feedback: false, require_submission_ready: false, after: nil, update_model: false
|
|
161
|
+
def perform_review_action(status:, require_feedback: false, require_submission_ready: false, after: nil, update_model: false,
|
|
162
|
+
from: nil)
|
|
150
163
|
begin
|
|
151
164
|
context_config
|
|
152
165
|
rescue ArgumentError => e
|
|
@@ -154,6 +167,20 @@ module Dscf
|
|
|
154
167
|
end
|
|
155
168
|
|
|
156
169
|
current_review = reviewable_resource.current_review_for(current_review_context)
|
|
170
|
+
current_status = current_review&.status || context_config[:initial_status]
|
|
171
|
+
|
|
172
|
+
# Check if action can be performed from the current status
|
|
173
|
+
if from.present?
|
|
174
|
+
allowed_from_statuses = Array(from).map(&:to_s)
|
|
175
|
+
unless allowed_from_statuses.include?(current_status)
|
|
176
|
+
error_message = "Action '#{action_name}' can only be performed from: " \
|
|
177
|
+
"#{allowed_from_statuses.join(', ')}. Current status is '#{current_status}'"
|
|
178
|
+
return render_error(
|
|
179
|
+
errors: [error_message],
|
|
180
|
+
status: :unprocessable_entity
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
157
184
|
|
|
158
185
|
# Check if submission is ready (for submit action)
|
|
159
186
|
if require_submission_ready && !submission_ready?(reviewable_resource)
|
|
@@ -172,10 +199,10 @@ module Dscf
|
|
|
172
199
|
feedback_data
|
|
173
200
|
end
|
|
174
201
|
|
|
175
|
-
unless valid_transition?(
|
|
202
|
+
unless valid_transition?(current_status, status)
|
|
176
203
|
return render_error(
|
|
177
204
|
errors: [
|
|
178
|
-
"Cannot transition from '#{
|
|
205
|
+
"Cannot transition from '#{current_status}' to '#{status}'"
|
|
179
206
|
]
|
|
180
207
|
)
|
|
181
208
|
end
|
|
@@ -68,6 +68,7 @@ module Dscf
|
|
|
68
68
|
# 1. Main record changes
|
|
69
69
|
main_changes = diff_records(before_main, after_main)
|
|
70
70
|
if main_changes.present?
|
|
71
|
+
# Apply main record filtering (supports only/except at root level)
|
|
71
72
|
filtered = filter_changes(main_changes, config[:only], config[:except])
|
|
72
73
|
if filtered.present?
|
|
73
74
|
structured_changes[:main][:changes] ||= {}
|
|
@@ -76,7 +77,10 @@ module Dscf
|
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
# 2. Associated record changes
|
|
79
|
-
|
|
80
|
+
# Support both old format (array) and new format (hash with per-association filters)
|
|
81
|
+
associations_config = normalize_associated_config(config[:associated])
|
|
82
|
+
|
|
83
|
+
associations_config.each do |assoc_name, assoc_options|
|
|
80
84
|
assoc_key = assoc_name.to_sym
|
|
81
85
|
before_assoc = before_associated[assoc_key] || []
|
|
82
86
|
after_assoc = after_associated[assoc_key] || []
|
|
@@ -84,8 +88,10 @@ module Dscf
|
|
|
84
88
|
assoc_changes = diff_associated_records(before_assoc, after_assoc, assoc_name)
|
|
85
89
|
next unless assoc_changes.present?
|
|
86
90
|
|
|
87
|
-
# Apply filtering
|
|
88
|
-
|
|
91
|
+
# Apply per-association filtering
|
|
92
|
+
assoc_only = assoc_options[:only]
|
|
93
|
+
assoc_except = assoc_options[:except]
|
|
94
|
+
filtered_assoc_changes = filter_associated_changes(assoc_changes, assoc_only, assoc_except)
|
|
89
95
|
next unless filtered_assoc_changes.present?
|
|
90
96
|
|
|
91
97
|
# Structure: { "reviews.123" => { action: "created", changes: {...} } }
|
|
@@ -115,6 +121,29 @@ module Dscf
|
|
|
115
121
|
structured_changes
|
|
116
122
|
end
|
|
117
123
|
|
|
124
|
+
# Normalize associated config to support both array and hash formats
|
|
125
|
+
# Input: [:reviews, :comments] or { reviews: { only: [:status] }, comments: {} }
|
|
126
|
+
# Output: { reviews: { only: [...], except: [...] }, comments: { only: nil, except: nil } }
|
|
127
|
+
def normalize_associated_config(associated)
|
|
128
|
+
return {} if associated.blank?
|
|
129
|
+
|
|
130
|
+
if associated.is_a?(Array)
|
|
131
|
+
# Old format: [:reviews, :comments] -> convert to hash with no filters
|
|
132
|
+
associated.each_with_object({}) { |name, h| h[name] = {only: nil, except: nil} }
|
|
133
|
+
elsif associated.is_a?(Hash)
|
|
134
|
+
# New format: already a hash, ensure each value is a hash with only/except
|
|
135
|
+
associated.transform_values do |value|
|
|
136
|
+
if value.is_a?(Hash)
|
|
137
|
+
{only: value[:only], except: value[:except]}
|
|
138
|
+
else
|
|
139
|
+
{only: nil, except: nil}
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
{}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
118
147
|
# Diff two record states (before/after)
|
|
119
148
|
def diff_records(before, after)
|
|
120
149
|
return {} if after.blank?
|
|
@@ -156,6 +185,7 @@ module Dscf
|
|
|
156
185
|
end
|
|
157
186
|
|
|
158
187
|
# Diff associated records (handle create/update/delete)
|
|
188
|
+
# Automatically excludes timestamp fields (created_at, updated_at) for associated records
|
|
159
189
|
def diff_associated_records(before_list, after_list, assoc_name)
|
|
160
190
|
changes = {}
|
|
161
191
|
|
|
@@ -167,9 +197,18 @@ module Dscf
|
|
|
167
197
|
after_map.each do |id, after_rec|
|
|
168
198
|
next if before_map[id]
|
|
169
199
|
|
|
200
|
+
# Convert attributes to change format (old: nil, new: value)
|
|
201
|
+
# Skip timestamp fields automatically
|
|
202
|
+
created_changes = {}
|
|
203
|
+
(after_rec[:attributes] || {}).each do |key, value|
|
|
204
|
+
next if %w[created_at updated_at].include?(key.to_s)
|
|
205
|
+
|
|
206
|
+
created_changes[key] = {old: nil, new: value}
|
|
207
|
+
end
|
|
208
|
+
|
|
170
209
|
changes["#{assoc_name}.#{id}"] = {
|
|
171
210
|
action: "created",
|
|
172
|
-
|
|
211
|
+
changes: created_changes
|
|
173
212
|
}
|
|
174
213
|
end
|
|
175
214
|
|
|
@@ -179,19 +218,32 @@ module Dscf
|
|
|
179
218
|
next unless after_rec
|
|
180
219
|
|
|
181
220
|
record_changes = diff_records(before_rec, after_rec)
|
|
221
|
+
# Skip timestamp fields from updates
|
|
222
|
+
record_changes.reject! { |key, _| %w[created_at updated_at].include?(key.to_s) }
|
|
182
223
|
next unless record_changes.present?
|
|
183
224
|
|
|
184
225
|
changes["#{assoc_name}.#{id}"] = {
|
|
185
226
|
action: "updated",
|
|
186
227
|
changes: record_changes
|
|
187
228
|
}
|
|
229
|
+
end
|
|
188
230
|
|
|
189
|
-
|
|
231
|
+
# Detect deleted records (in before but not in after)
|
|
232
|
+
before_map.each do |id, before_rec|
|
|
190
233
|
next if after_map[id]
|
|
191
234
|
|
|
235
|
+
# Convert attributes to change format (old: value, new: nil)
|
|
236
|
+
# Skip timestamp fields automatically
|
|
237
|
+
deleted_changes = {}
|
|
238
|
+
(before_rec[:attributes] || {}).each do |key, value|
|
|
239
|
+
next if %w[created_at updated_at].include?(key.to_s)
|
|
240
|
+
|
|
241
|
+
deleted_changes[key] = {old: value, new: nil}
|
|
242
|
+
end
|
|
243
|
+
|
|
192
244
|
changes["#{assoc_name}.#{id}"] = {
|
|
193
245
|
action: "deleted",
|
|
194
|
-
|
|
246
|
+
changes: deleted_changes
|
|
195
247
|
}
|
|
196
248
|
end
|
|
197
249
|
|
data/lib/dscf/core/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dscf-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asrat
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2025-11-
|
|
10
|
+
date: 2025-11-12 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|