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: 230e498b674cb3acf80fd9c7637b89c3e8da0e697a108f5d1b8998b9218eda59
4
- data.tar.gz: d62246472662f80db9df2478a08afa9eebd163af916aa9a70f408ccbe695bf25
3
+ metadata.gz: 26fe3945c60e59f5abf2ccff51c02da617f1c063cf19c903c5505567ceed13ca
4
+ data.tar.gz: 57d0f65ee3a45cb0a8e5560fa90cf99ac1ec6f09ef7c161900eb3357c945ac89
5
5
  SHA512:
6
- metadata.gz: aadfeecee56e8ffc6ade9f47f244a7e313b23e23913dd99636c0bcff48fa53deb029c0461c8f92d4094352932f99f9941c3de5c97987a5f71bad92aec01364e5
7
- data.tar.gz: 5ec8e1e44f084814ba4139be60f4f9c306f42eb383aced86d12764c61a205369242a09e72450f98104e7cb15bece45680210473827dbb691d9c05388581b188b
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
- # auditable associated: [:reviews], only: [:status], on: [:approve, :reject]
20
- # auditable only: [:name, :email], on: [:create, :update]
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: Array(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 is array of symbols
39
- Array(associated).each do |assoc|
40
- unless assoc.is_a?(Symbol)
41
- raise ArgumentError, "auditable associated: must be an array of symbols, got #{assoc.class} for #{assoc.inspect}"
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
- config[:associated].each do |assoc_name|
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
- config[:associated].each do |assoc_name|
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
- next if statuses.include?(opts[:status])
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}' maps to invalid status '#{opts[:status]}'. Must be one of: #{statuses.join(', ')}
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?(current_review&.status, status)
202
+ unless valid_transition?(current_status, status)
176
203
  return render_error(
177
204
  errors: [
178
- "Cannot transition from '#{current_review&.status || context_config[:initial_status]}' to '#{status}'"
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
- config[:associated].each do |assoc_name|
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 to associated changes
88
- filtered_assoc_changes = filter_associated_changes(assoc_changes, config[:only], config[:except])
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
- attributes: after_rec[:attributes]
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
- # Detect deleted records (in before but not in after)
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
- attributes: before_rec[:attributes]
246
+ changes: deleted_changes
195
247
  }
196
248
  end
197
249
 
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Core
3
- VERSION = "0.2.6".freeze
3
+ VERSION = "0.2.7".freeze
4
4
  end
5
5
  end
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.6
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-03 00:00:00.000000000 Z
10
+ date: 2025-11-12 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails