magick-feature-flags 1.4.3 → 1.5.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 +63 -3
- data/app/controllers/magick/adminui/features_controller.rb +19 -0
- data/config/magick.rb.example +5 -3
- data/lib/generators/magick/install/templates/magick.rb +5 -3
- data/lib/magick/adapters/registry.rb +8 -2
- data/lib/magick/admin_ui.rb +5 -1
- data/lib/magick/config.rb +10 -4
- data/lib/magick/feature.rb +304 -166
- data/lib/magick/rails/railtie.rb +5 -2
- data/lib/magick/version.rb +1 -1
- data/lib/magick/versioning.rb +205 -51
- data/lib/magick.rb +61 -0
- metadata +1 -1
data/lib/magick/feature.rb
CHANGED
|
@@ -233,187 +233,246 @@ module Magick
|
|
|
233
233
|
end
|
|
234
234
|
|
|
235
235
|
def enable_for_user(user_id)
|
|
236
|
-
|
|
236
|
+
record_change('enable_for_user', targeting_change(:user, added: user_id)) do
|
|
237
|
+
enable_targeting(:user, user_id)
|
|
238
|
+
end
|
|
237
239
|
true
|
|
238
240
|
end
|
|
239
241
|
|
|
240
242
|
def disable_for_user(user_id)
|
|
241
|
-
|
|
243
|
+
record_change('disable_for_user', targeting_change(:user, removed: user_id)) do
|
|
244
|
+
disable_targeting(:user, user_id)
|
|
245
|
+
end
|
|
242
246
|
true
|
|
243
247
|
end
|
|
244
248
|
|
|
245
249
|
def enable_for_group(group_name)
|
|
246
|
-
|
|
250
|
+
record_change('enable_for_group', targeting_change(:group, added: group_name)) do
|
|
251
|
+
enable_targeting(:group, group_name)
|
|
252
|
+
end
|
|
247
253
|
true
|
|
248
254
|
end
|
|
249
255
|
|
|
250
256
|
def disable_for_group(group_name)
|
|
251
|
-
|
|
257
|
+
record_change('disable_for_group', targeting_change(:group, removed: group_name)) do
|
|
258
|
+
disable_targeting(:group, group_name)
|
|
259
|
+
end
|
|
252
260
|
true
|
|
253
261
|
end
|
|
254
262
|
|
|
255
263
|
def enable_for_role(role_name)
|
|
256
|
-
|
|
264
|
+
record_change('enable_for_role', targeting_change(:role, added: role_name)) do
|
|
265
|
+
enable_targeting(:role, role_name)
|
|
266
|
+
end
|
|
257
267
|
true
|
|
258
268
|
end
|
|
259
269
|
|
|
260
270
|
def disable_for_role(role_name)
|
|
261
|
-
|
|
271
|
+
record_change('disable_for_role', targeting_change(:role, removed: role_name)) do
|
|
272
|
+
disable_targeting(:role, role_name)
|
|
273
|
+
end
|
|
262
274
|
true
|
|
263
275
|
end
|
|
264
276
|
|
|
265
277
|
def enable_for_tag(tag_name)
|
|
266
|
-
|
|
278
|
+
record_change('enable_for_tag', targeting_change(:tag, added: tag_name)) do
|
|
279
|
+
enable_targeting(:tag, tag_name)
|
|
280
|
+
end
|
|
267
281
|
true
|
|
268
282
|
end
|
|
269
283
|
|
|
270
284
|
def disable_for_tag(tag_name)
|
|
271
|
-
|
|
285
|
+
record_change('disable_for_tag', targeting_change(:tag, removed: tag_name)) do
|
|
286
|
+
disable_targeting(:tag, tag_name)
|
|
287
|
+
end
|
|
272
288
|
true
|
|
273
289
|
end
|
|
274
290
|
|
|
275
291
|
# --- Exclusion methods ---
|
|
276
292
|
|
|
277
293
|
def exclude_user(user_id)
|
|
278
|
-
|
|
294
|
+
record_change('exclude_user', targeting_change(:excluded_users, added: user_id)) do
|
|
295
|
+
enable_targeting(:excluded_users, user_id)
|
|
296
|
+
end
|
|
279
297
|
true
|
|
280
298
|
end
|
|
281
299
|
|
|
282
300
|
def remove_user_exclusion(user_id)
|
|
283
|
-
|
|
301
|
+
record_change('remove_user_exclusion', targeting_change(:excluded_users, removed: user_id)) do
|
|
302
|
+
disable_targeting(:excluded_users, user_id)
|
|
303
|
+
end
|
|
284
304
|
true
|
|
285
305
|
end
|
|
286
306
|
|
|
287
307
|
def exclude_tag(tag_name)
|
|
288
|
-
|
|
308
|
+
record_change('exclude_tag', targeting_change(:excluded_tags, added: tag_name)) do
|
|
309
|
+
enable_targeting(:excluded_tags, tag_name)
|
|
310
|
+
end
|
|
289
311
|
true
|
|
290
312
|
end
|
|
291
313
|
|
|
292
314
|
def remove_tag_exclusion(tag_name)
|
|
293
|
-
|
|
315
|
+
record_change('remove_tag_exclusion', targeting_change(:excluded_tags, removed: tag_name)) do
|
|
316
|
+
disable_targeting(:excluded_tags, tag_name)
|
|
317
|
+
end
|
|
294
318
|
true
|
|
295
319
|
end
|
|
296
320
|
|
|
297
321
|
def exclude_group(group_name)
|
|
298
|
-
|
|
322
|
+
record_change('exclude_group', targeting_change(:excluded_groups, added: group_name)) do
|
|
323
|
+
enable_targeting(:excluded_groups, group_name)
|
|
324
|
+
end
|
|
299
325
|
true
|
|
300
326
|
end
|
|
301
327
|
|
|
302
328
|
def remove_group_exclusion(group_name)
|
|
303
|
-
|
|
329
|
+
record_change('remove_group_exclusion', targeting_change(:excluded_groups, removed: group_name)) do
|
|
330
|
+
disable_targeting(:excluded_groups, group_name)
|
|
331
|
+
end
|
|
304
332
|
true
|
|
305
333
|
end
|
|
306
334
|
|
|
307
335
|
def exclude_role(role_name)
|
|
308
|
-
|
|
336
|
+
record_change('exclude_role', targeting_change(:excluded_roles, added: role_name)) do
|
|
337
|
+
enable_targeting(:excluded_roles, role_name)
|
|
338
|
+
end
|
|
309
339
|
true
|
|
310
340
|
end
|
|
311
341
|
|
|
312
342
|
def remove_role_exclusion(role_name)
|
|
313
|
-
|
|
343
|
+
record_change('remove_role_exclusion', targeting_change(:excluded_roles, removed: role_name)) do
|
|
344
|
+
disable_targeting(:excluded_roles, role_name)
|
|
345
|
+
end
|
|
314
346
|
true
|
|
315
347
|
end
|
|
316
348
|
|
|
317
349
|
def exclude_ip_addresses(ip_addresses)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
350
|
+
ips = Array(ip_addresses).map(&:to_s)
|
|
351
|
+
record_change('exclude_ip_addresses', targeting_change(:excluded_ip_addresses, added: ips)) do
|
|
352
|
+
# excluded_ip_addresses is stored as a flat Array of strings; bypass the
|
|
353
|
+
# generic enable_targeting path whose "array type" branch stringifies the
|
|
354
|
+
# incoming array into a single element.
|
|
355
|
+
@targeting[:excluded_ip_addresses] ||= []
|
|
356
|
+
ips.each do |str|
|
|
357
|
+
@targeting[:excluded_ip_addresses] << str unless @targeting[:excluded_ip_addresses].include?(str)
|
|
358
|
+
end
|
|
359
|
+
save_targeting
|
|
325
360
|
end
|
|
326
|
-
save_targeting
|
|
327
361
|
true
|
|
328
362
|
end
|
|
329
363
|
|
|
330
364
|
def remove_ip_exclusion
|
|
331
|
-
|
|
365
|
+
record_change('remove_ip_exclusion', targeting_change(:excluded_ip_addresses)) do
|
|
366
|
+
disable_targeting(:excluded_ip_addresses)
|
|
367
|
+
end
|
|
332
368
|
true
|
|
333
369
|
end
|
|
334
370
|
|
|
335
371
|
def enable_percentage_of_users(percentage)
|
|
336
|
-
|
|
337
|
-
|
|
372
|
+
record_change('enable_percentage_of_users', targeting_change(:percentage_users, added: percentage.to_f)) do
|
|
373
|
+
@targeting[:percentage_users] = percentage.to_f
|
|
374
|
+
save_targeting
|
|
338
375
|
|
|
339
|
-
|
|
340
|
-
|
|
376
|
+
# Update registered feature instance if it exists
|
|
377
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
|
|
341
378
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
379
|
+
# Rails 8+ event
|
|
380
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
381
|
+
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage)
|
|
382
|
+
end
|
|
345
383
|
end
|
|
346
384
|
|
|
347
385
|
true
|
|
348
386
|
end
|
|
349
387
|
|
|
350
388
|
def disable_percentage_of_users
|
|
351
|
-
|
|
389
|
+
record_change('disable_percentage_of_users', targeting_change(:percentage_users)) do
|
|
390
|
+
disable_targeting(:percentage_users)
|
|
391
|
+
end
|
|
352
392
|
true
|
|
353
393
|
end
|
|
354
394
|
|
|
355
395
|
def enable_percentage_of_requests(percentage)
|
|
356
|
-
|
|
357
|
-
|
|
396
|
+
record_change('enable_percentage_of_requests', targeting_change(:percentage_requests, added: percentage.to_f)) do
|
|
397
|
+
@targeting[:percentage_requests] = percentage.to_f
|
|
398
|
+
save_targeting
|
|
358
399
|
|
|
359
|
-
|
|
360
|
-
|
|
400
|
+
# Update registered feature instance if it exists
|
|
401
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
|
|
361
402
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
403
|
+
# Rails 8+ event
|
|
404
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
405
|
+
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage)
|
|
406
|
+
end
|
|
365
407
|
end
|
|
366
408
|
|
|
367
409
|
true
|
|
368
410
|
end
|
|
369
411
|
|
|
370
412
|
def disable_percentage_of_requests
|
|
371
|
-
|
|
413
|
+
record_change('disable_percentage_of_requests', targeting_change(:percentage_requests)) do
|
|
414
|
+
disable_targeting(:percentage_requests)
|
|
415
|
+
end
|
|
372
416
|
true
|
|
373
417
|
end
|
|
374
418
|
|
|
375
419
|
def enable_for_date_range(start_date, end_date)
|
|
376
|
-
|
|
420
|
+
record_change('enable_for_date_range',
|
|
421
|
+
targeting_change(:date_range, added: { start: start_date, end: end_date })) do
|
|
422
|
+
enable_targeting(:date_range, { start: start_date, end: end_date })
|
|
423
|
+
end
|
|
377
424
|
true
|
|
378
425
|
end
|
|
379
426
|
|
|
380
427
|
def disable_date_range
|
|
381
|
-
|
|
428
|
+
record_change('disable_date_range', targeting_change(:date_range)) do
|
|
429
|
+
disable_targeting(:date_range)
|
|
430
|
+
end
|
|
382
431
|
true
|
|
383
432
|
end
|
|
384
433
|
|
|
385
434
|
def enable_for_ip_addresses(ip_addresses)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
435
|
+
ips = Array(ip_addresses).map(&:to_s)
|
|
436
|
+
record_change('enable_for_ip_addresses', targeting_change(:ip_address, added: ips)) do
|
|
437
|
+
# ip_address is stored as a flat Array of strings; bypass the generic
|
|
438
|
+
# enable_targeting path whose "array type" branch stringifies the
|
|
439
|
+
# incoming array into a single '["x.y.z"]' entry.
|
|
440
|
+
@targeting[:ip_address] ||= []
|
|
441
|
+
ips.each do |str|
|
|
442
|
+
@targeting[:ip_address] << str unless @targeting[:ip_address].include?(str)
|
|
443
|
+
end
|
|
444
|
+
save_targeting
|
|
393
445
|
end
|
|
394
|
-
save_targeting
|
|
395
446
|
true
|
|
396
447
|
end
|
|
397
448
|
|
|
398
449
|
def disable_ip_addresses
|
|
399
|
-
|
|
450
|
+
record_change('disable_ip_addresses', targeting_change(:ip_address)) do
|
|
451
|
+
disable_targeting(:ip_address)
|
|
452
|
+
end
|
|
400
453
|
true
|
|
401
454
|
end
|
|
402
455
|
|
|
403
456
|
def enable_for_custom_attribute(attribute_name, values, operator: :equals)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
457
|
+
change = targeting_change(:custom_attributes,
|
|
458
|
+
added: { attribute: attribute_name, values: Array(values), operator: operator })
|
|
459
|
+
record_change('enable_for_custom_attribute', change) do
|
|
460
|
+
custom_attrs = targeting[:custom_attributes] || {}
|
|
461
|
+
custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator }
|
|
462
|
+
enable_targeting(:custom_attributes, custom_attrs)
|
|
463
|
+
end
|
|
407
464
|
true
|
|
408
465
|
end
|
|
409
466
|
|
|
410
467
|
def disable_custom_attribute(attribute_name)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
468
|
+
record_change('disable_custom_attribute', targeting_change(:custom_attributes, removed: attribute_name)) do
|
|
469
|
+
custom_attrs = targeting[:custom_attributes] || {}
|
|
470
|
+
custom_attrs.delete(attribute_name.to_sym)
|
|
471
|
+
if custom_attrs.empty?
|
|
472
|
+
disable_targeting(:custom_attributes)
|
|
473
|
+
else
|
|
474
|
+
enable_targeting(:custom_attributes, custom_attrs)
|
|
475
|
+
end
|
|
417
476
|
end
|
|
418
477
|
true
|
|
419
478
|
end
|
|
@@ -422,34 +481,42 @@ module Magick
|
|
|
422
481
|
variants_array = Array(variants).map do |v|
|
|
423
482
|
v.is_a?(FeatureVariant) ? v : FeatureVariant.new(v[:name], v[:value], weight: v[:weight] || 0)
|
|
424
483
|
end
|
|
425
|
-
|
|
484
|
+
payload = variants_array.map(&:to_h)
|
|
426
485
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
486
|
+
record_change('set_variants', { variants: payload }) do
|
|
487
|
+
enable_targeting(:variants, payload)
|
|
488
|
+
|
|
489
|
+
# Rails 8+ event
|
|
490
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
491
|
+
Magick::Rails::Events.variant_set(name, variants: variants_array)
|
|
492
|
+
end
|
|
430
493
|
end
|
|
431
494
|
|
|
432
495
|
true
|
|
433
496
|
end
|
|
434
497
|
|
|
435
498
|
def add_dependency(dependency_name)
|
|
436
|
-
|
|
437
|
-
|
|
499
|
+
record_change('add_dependency', { dependency: { added: dependency_name.to_s } }) do
|
|
500
|
+
@dependencies ||= []
|
|
501
|
+
@dependencies << dependency_name.to_s unless @dependencies.include?(dependency_name.to_s)
|
|
438
502
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
503
|
+
# Rails 8+ event
|
|
504
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
505
|
+
Magick::Rails::Events.dependency_added(name, dependency_name)
|
|
506
|
+
end
|
|
442
507
|
end
|
|
443
508
|
|
|
444
509
|
true
|
|
445
510
|
end
|
|
446
511
|
|
|
447
512
|
def remove_dependency(dependency_name)
|
|
448
|
-
|
|
513
|
+
record_change('remove_dependency', { dependency: { removed: dependency_name.to_s } }) do
|
|
514
|
+
@dependencies&.delete(dependency_name.to_s)
|
|
449
515
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
516
|
+
# Rails 8+ event
|
|
517
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
518
|
+
Magick::Rails::Events.dependency_removed(name, dependency_name)
|
|
519
|
+
end
|
|
453
520
|
end
|
|
454
521
|
|
|
455
522
|
true
|
|
@@ -501,90 +568,93 @@ module Magick
|
|
|
501
568
|
old_value = @stored_value
|
|
502
569
|
validate_value!(value)
|
|
503
570
|
|
|
504
|
-
|
|
505
|
-
data = { 'value' => value, 'type' => type, 'status' => status, 'default_value' => default_value }
|
|
506
|
-
data['description'] = description if description
|
|
507
|
-
data['display_name'] = display_name if display_name
|
|
508
|
-
data['group'] = group if group
|
|
509
|
-
adapter_registry.set_all_data(name, data)
|
|
571
|
+
changes = { value: { from: old_value, to: value } }
|
|
510
572
|
|
|
511
|
-
|
|
512
|
-
|
|
573
|
+
record_change('set_value', changes, user_id: user_id) do
|
|
574
|
+
# Bulk write all metadata in a single adapter call instead of 7 separate calls
|
|
575
|
+
data = { 'value' => value, 'type' => type, 'status' => status, 'default_value' => default_value }
|
|
576
|
+
data['description'] = description if description
|
|
577
|
+
data['display_name'] = display_name if display_name
|
|
578
|
+
data['group'] = group if group
|
|
579
|
+
adapter_registry.set_all_data(name, data)
|
|
513
580
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
registered = Magick.features[name]
|
|
517
|
-
registered.instance_variable_set(:@stored_value, value)
|
|
518
|
-
registered.instance_variable_set(:@stored_value_initialized, true)
|
|
519
|
-
registered.instance_variable_set(:@targeting, @targeting.dup) if @targeting
|
|
520
|
-
end
|
|
521
|
-
|
|
522
|
-
changes = { value: { from: old_value, to: value } }
|
|
581
|
+
@stored_value = value
|
|
582
|
+
@stored_value_initialized = true
|
|
523
583
|
|
|
524
|
-
|
|
525
|
-
name
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
584
|
+
# Update registered feature instance if it exists
|
|
585
|
+
if Magick.features.key?(name)
|
|
586
|
+
registered = Magick.features[name]
|
|
587
|
+
registered.instance_variable_set(:@stored_value, value)
|
|
588
|
+
registered.instance_variable_set(:@stored_value_initialized, true)
|
|
589
|
+
registered.instance_variable_set(:@targeting, @targeting.dup) if @targeting
|
|
590
|
+
end
|
|
530
591
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
592
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
593
|
+
Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
|
|
594
|
+
end
|
|
534
595
|
end
|
|
535
596
|
|
|
536
597
|
true
|
|
537
598
|
end
|
|
538
599
|
|
|
539
600
|
def enable(user_id: nil)
|
|
540
|
-
|
|
541
|
-
@targeting = {}
|
|
542
|
-
save_targeting
|
|
601
|
+
changes = { value: { from: @stored_value, to: true }, targeting: { cleared: true } }
|
|
543
602
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
603
|
+
record_change('enable', changes, user_id: user_id) do
|
|
604
|
+
# Clear all targeting to enable globally
|
|
605
|
+
@targeting = {}
|
|
606
|
+
save_targeting
|
|
607
|
+
|
|
608
|
+
case type
|
|
609
|
+
when :boolean
|
|
610
|
+
set_value(true, user_id: user_id)
|
|
611
|
+
when :string
|
|
612
|
+
raise InvalidFeatureValueError, 'Cannot enable string feature. Use set_value instead.'
|
|
613
|
+
when :number
|
|
614
|
+
raise InvalidFeatureValueError, 'Cannot enable number feature. Use set_value instead.'
|
|
615
|
+
else
|
|
616
|
+
raise InvalidFeatureValueError, "Cannot enable feature of type #{type}"
|
|
617
|
+
end
|
|
554
618
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
619
|
+
# Rails 8+ event
|
|
620
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
621
|
+
Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id)
|
|
622
|
+
end
|
|
558
623
|
end
|
|
559
624
|
|
|
560
625
|
true
|
|
561
626
|
end
|
|
562
627
|
|
|
563
628
|
def disable(user_id: nil)
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
save_targeting
|
|
629
|
+
disabled_value = { boolean: false, string: '', number: 0 }[type]
|
|
630
|
+
changes = { value: { from: @stored_value, to: disabled_value }, targeting: { cleared: true } }
|
|
567
631
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
632
|
+
record_change('disable', changes, user_id: user_id) do
|
|
633
|
+
# Clear all targeting to disable globally
|
|
634
|
+
@targeting = {}
|
|
635
|
+
save_targeting
|
|
636
|
+
|
|
637
|
+
case type
|
|
638
|
+
when :boolean
|
|
639
|
+
set_value(false, user_id: user_id)
|
|
640
|
+
when :string
|
|
641
|
+
set_value('', user_id: user_id)
|
|
642
|
+
when :number
|
|
643
|
+
set_value(0, user_id: user_id)
|
|
644
|
+
else
|
|
645
|
+
raise InvalidFeatureValueError, "Cannot disable feature of type #{type}"
|
|
646
|
+
end
|
|
578
647
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
648
|
+
# Ensure registered feature instance also has targeting cleared
|
|
649
|
+
if Magick.features.key?(name)
|
|
650
|
+
registered = Magick.features[name]
|
|
651
|
+
registered.instance_variable_set(:@targeting, {})
|
|
652
|
+
end
|
|
584
653
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
654
|
+
# Rails 8+ event
|
|
655
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
656
|
+
Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
|
|
657
|
+
end
|
|
588
658
|
end
|
|
589
659
|
|
|
590
660
|
true
|
|
@@ -593,34 +663,41 @@ module Magick
|
|
|
593
663
|
def set_status(new_status)
|
|
594
664
|
raise InvalidFeatureValueError, "Invalid status: #{new_status}" unless VALID_STATUSES.include?(new_status.to_sym)
|
|
595
665
|
|
|
596
|
-
@status
|
|
597
|
-
|
|
666
|
+
record_change('set_status', { status: { from: @status, to: new_status.to_sym } }) do
|
|
667
|
+
@status = new_status.to_sym
|
|
668
|
+
adapter_registry.set(name, 'status', status)
|
|
669
|
+
end
|
|
598
670
|
true
|
|
599
671
|
end
|
|
600
672
|
|
|
601
673
|
def set_group(group_name)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
@group = group_name.to_s.strip
|
|
674
|
+
new_group = group_name.nil? || group_name.to_s.strip.empty? ? nil : group_name.to_s.strip
|
|
675
|
+
|
|
676
|
+
record_change('set_group', { group: { from: @group, to: new_group } }) do
|
|
677
|
+
@group = new_group
|
|
678
|
+
# Clear group from adapter by setting to nil (adapters handle this)
|
|
608
679
|
adapter_registry.set(name, 'group', @group)
|
|
609
|
-
end
|
|
610
680
|
|
|
611
|
-
|
|
612
|
-
|
|
681
|
+
# Update registered feature instance if it exists
|
|
682
|
+
Magick.features[name].instance_variable_set(:@group, @group) if Magick.features.key?(name)
|
|
683
|
+
end
|
|
613
684
|
|
|
614
685
|
true
|
|
615
686
|
end
|
|
616
687
|
|
|
617
688
|
def delete
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
689
|
+
# Snapshot before the wipe so the recorded version captures the state
|
|
690
|
+
# this feature had when it was deleted, not the emptied one.
|
|
691
|
+
snapshot = to_h
|
|
692
|
+
|
|
693
|
+
record_change('delete', { deleted: true }, snapshot: snapshot) do
|
|
694
|
+
adapter_registry.delete(name)
|
|
695
|
+
@stored_value = nil
|
|
696
|
+
@stored_value_initialized = false # Reset initialization flag so get_value returns default_value
|
|
697
|
+
@targeting = {}
|
|
698
|
+
# Also remove from Magick.features if registered
|
|
699
|
+
Magick.features.delete(name.to_s)
|
|
700
|
+
end
|
|
624
701
|
true
|
|
625
702
|
end
|
|
626
703
|
|
|
@@ -686,6 +763,69 @@ module Magick
|
|
|
686
763
|
end
|
|
687
764
|
|
|
688
765
|
def save_targeting
|
|
766
|
+
# Records only when called directly (e.g. Admin UI clearing variants);
|
|
767
|
+
# when reached through a wrapping mutator the guard is active and this
|
|
768
|
+
# just persists.
|
|
769
|
+
record_change('update_targeting', { targeting: targeting.dup }) do
|
|
770
|
+
persist_targeting
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# Restore full feature state from a version snapshot (used by
|
|
775
|
+
# Versioning#rollback). Replaces value (including false/empty), status,
|
|
776
|
+
# group, the entire targeting hash and dependencies wholesale.
|
|
777
|
+
def restore_snapshot!(data)
|
|
778
|
+
set_status(data[:status]) if data[:status]
|
|
779
|
+
set_group(data[:group]) if data.key?(:group)
|
|
780
|
+
|
|
781
|
+
value = data[:value]
|
|
782
|
+
set_value(cast_value(value)) unless value.nil?
|
|
783
|
+
|
|
784
|
+
@targeting = normalize_targeting(data[:targeting])
|
|
785
|
+
save_targeting
|
|
786
|
+
|
|
787
|
+
@dependencies = Array(data[:dependencies]).map(&:to_s)
|
|
788
|
+
true
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
private
|
|
792
|
+
|
|
793
|
+
attr_reader :targeting
|
|
794
|
+
|
|
795
|
+
# Single choke point for change tracking: wraps a public mutator's body,
|
|
796
|
+
# then writes one audit entry and one version snapshot for the operation.
|
|
797
|
+
# A thread-local guard makes nested mutator calls (enable -> set_value)
|
|
798
|
+
# silent, so one logical operation records exactly once, under its real
|
|
799
|
+
# action name. No-op while definitions are being (re)applied at boot.
|
|
800
|
+
def record_change(action, changes = {}, user_id: nil, snapshot: nil)
|
|
801
|
+
if Magick.change_recording_suppressed?
|
|
802
|
+
return block_given? ? yield : true
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
result = block_given? ? Magick.suppress_change_recording { yield } : true
|
|
806
|
+
|
|
807
|
+
actor = user_id || Magick.current_actor
|
|
808
|
+
Magick.audit_log&.log(name, action, user_id: actor, changes: changes)
|
|
809
|
+
|
|
810
|
+
if Magick.versioning_enabled?
|
|
811
|
+
begin
|
|
812
|
+
Magick.versioning&.record_change(self, action: action, created_by: actor, snapshot: snapshot)
|
|
813
|
+
rescue StandardError => e
|
|
814
|
+
warn "Magick: Failed to record version for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
result
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def targeting_change(type, added: nil, removed: nil)
|
|
822
|
+
change = { type: type }
|
|
823
|
+
change[:added] = added unless added.nil?
|
|
824
|
+
change[:removed] = removed unless removed.nil?
|
|
825
|
+
{ targeting: change }
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def persist_targeting
|
|
689
829
|
# Save targeting to adapter (this updates memory synchronously, then Redis/AR)
|
|
690
830
|
# The set method already publishes cache invalidation to other processes via Pub/Sub
|
|
691
831
|
adapter_registry.set(name, 'targeting', targeting)
|
|
@@ -706,10 +846,6 @@ module Magick
|
|
|
706
846
|
# 3. The set method handles both sync and async Redis updates correctly
|
|
707
847
|
end
|
|
708
848
|
|
|
709
|
-
private
|
|
710
|
-
|
|
711
|
-
attr_reader :targeting
|
|
712
|
-
|
|
713
849
|
def stored_value
|
|
714
850
|
return @stored_value if @stored_value_initialized
|
|
715
851
|
|
|
@@ -748,14 +884,7 @@ module Magick
|
|
|
748
884
|
group_value = all_data['group']
|
|
749
885
|
@group = group_value if group_value
|
|
750
886
|
|
|
751
|
-
|
|
752
|
-
if targeting_value.is_a?(Hash)
|
|
753
|
-
@targeting = targeting_value.transform_keys(&:to_sym)
|
|
754
|
-
@targeting[:percentage_users] = @targeting[:percentage_users].to_f if @targeting[:percentage_users]
|
|
755
|
-
@targeting[:percentage_requests] = @targeting[:percentage_requests].to_f if @targeting[:percentage_requests]
|
|
756
|
-
else
|
|
757
|
-
@targeting = {}
|
|
758
|
-
end
|
|
887
|
+
@targeting = normalize_targeting(all_data['targeting'])
|
|
759
888
|
|
|
760
889
|
# Track what was loaded so save_metadata_if_new can skip unnecessary writes
|
|
761
890
|
@_loaded_description = all_data['description']
|
|
@@ -763,6 +892,15 @@ module Magick
|
|
|
763
892
|
@_loaded_group = all_data['group']
|
|
764
893
|
end
|
|
765
894
|
|
|
895
|
+
def normalize_targeting(raw)
|
|
896
|
+
return {} unless raw.is_a?(Hash)
|
|
897
|
+
|
|
898
|
+
normalized = raw.transform_keys(&:to_sym)
|
|
899
|
+
normalized[:percentage_users] = normalized[:percentage_users].to_f if normalized[:percentage_users]
|
|
900
|
+
normalized[:percentage_requests] = normalized[:percentage_requests].to_f if normalized[:percentage_requests]
|
|
901
|
+
normalized
|
|
902
|
+
end
|
|
903
|
+
|
|
766
904
|
def save_metadata_if_new
|
|
767
905
|
# Only write to adapter if the DSL value differs from what was loaded
|
|
768
906
|
# This avoids unnecessary find_or_initialize_by + save! calls on every boot
|