Feature: Installation global config (#839) (#840)

* Renamed concern from Feature to Featurable

* Feature: Installation config (#839)
* Added new model installtion config with corresponding migrations and specs
* Created an installation config yml (key value store model)
* Created a config loader module to load the installaltion configs
* Added this to the config loader seeder
* Changed the account before create hook for default feature enabling to use the feature values from installtion config
* Renamed the feature concern to Featurable to follow the naming pattern for concerns
* Added comments and specs for modules and places that deemed necessary

* Refactored config loader to reduce cognitive complexity (#839)
This commit is contained in:
Sony Mathew 2020-05-10 22:40:36 +05:30 committed by GitHub
parent 76b98cbed4
commit 905c93b8f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 203 additions and 18 deletions

View file

@ -94,6 +94,8 @@ Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
RSpec/NamedSubject:
Enabled: false
AllCops:
Exclude:
- 'bin/**/*'

View file

@ -19,7 +19,7 @@ class Account < ApplicationRecord
include Events::Types
include Reportable
include Features
include Featurable
DEFAULT_QUERY_SETTING = {
flag_query_mode: :bit_operator

View file

@ -1,4 +1,4 @@
module Features
module Featurable
extend ActiveSupport::Concern
QUERY_MODE = {
@ -51,7 +51,10 @@ module Features
private
def enable_default_features
features_to_enabled = FEATURE_LIST.select { |f| f['enabled'] }.map { |f| f['name'] }
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
return true if config.blank?
features_to_enabled = config.value.select { |f| f[:enabled] }.map { |f| f[:name] }
enable_features(features_to_enabled)
end
end

View file

@ -0,0 +1,31 @@
# == Schema Information
#
# Table name: installation_configs
#
# id :bigint not null, primary key
# name :string not null
# serialized_value :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_installation_configs_on_name_and_created_at (name,created_at) UNIQUE
#
class InstallationConfig < ApplicationRecord
serialize :serialized_value, HashWithIndifferentAccess
validates :name, presence: true
default_scope { order(created_at: :desc) }
def value
serialized_value[:value]
end
def value=(value_to_assigned)
self.serialized_value = {
value: value_to_assigned
}.with_indifferent_access
end
end

View file

@ -0,0 +1,2 @@
- name: SHOW_WIDGET_HEADER
value: true

View file

@ -0,0 +1,13 @@
class CreateInstallationConfig < ActiveRecord::Migration[6.0]
def change
create_table :installation_configs do |t|
t.string :name, null: false
t.jsonb :serialized_value, null: false, default: '{}'
t.timestamps
end
add_index :installation_configs, [:name, :created_at], unique: true
ConfigLoader.new.process
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_05_09_044639) do
ActiveRecord::Schema.define(version: 2020_05_10_112339) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
@ -246,6 +246,14 @@ ActiveRecord::Schema.define(version: 2020_05_09_044639) do
t.index ["account_id"], name: "index_inboxes_on_account_id"
end
create_table "installation_configs", force: :cascade do |t|
t.string "name", null: false
t.jsonb "serialized_value", default: "{}", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["name", "created_at"], name: "index_installation_configs_on_name_and_created_at", unique: true
end
create_table "messages", id: :serial, force: :cascade do |t|
t.text "content"
t.integer "account_id", null: false
@ -319,20 +327,6 @@ ActiveRecord::Schema.define(version: 2020_05_09_044639) do
t.boolean "payment_source_added", default: false
end
create_table "super_admins", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
t.datetime "remember_created_at"
t.integer "sign_in_count", default: 0, null: false
t.datetime "current_sign_in_at"
t.datetime "last_sign_in_at"
t.inet "current_sign_in_ip"
t.inet "last_sign_in_ip"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["email"], name: "index_super_admins_on_email", unique: true
end
create_table "taggings", id: :serial, force: :cascade do |t|
t.integer "tag_id"
t.string "taggable_type"

View file

@ -1,3 +1,6 @@
# loading installation configs
ConfigLoader.new.process
account = Account.create!(
name: 'Acme Inc',
domain: 'support.chatwoot.com',

84
lib/config_loader.rb Normal file
View file

@ -0,0 +1,84 @@
class ConfigLoader
DEFAULT_OPTIONS = {
config_path: nil,
reconcile_only_new: true
}.freeze
def process(options = {})
options = DEFAULT_OPTIONS.merge(options)
# function of the "reconcile_only_new" flag
# if true,
# it leaves the existing config and feature flags as it is and
# creates the missing configs and feature flags with their default values
# if false,
# then it overwrites existing config and feature flags with default values
# also creates the missing configs and feature flags with their default values
@reconcile_only_new = options[:reconcile_only_new]
# setting the config path
@config_path = options[:config_path].presence
@config_path ||= Rails.root.join('config')
# general installation configs
reconcile_general_config
# default account based feature configs
reconcile_feature_config
end
private
def general_configs
@general_configs ||= YAML.safe_load(File.read("#{@config_path}/installation_config.yml")).freeze
end
def account_features
@account_features ||= YAML.safe_load(File.read("#{@config_path}/features.yml")).freeze
end
def reconcile_general_config
general_configs.each do |config|
new_config = config.with_indifferent_access
existing_config = InstallationConfig.find_by(name: new_config[:name])
save_general_config(existing_config, new_config)
end
end
def save_general_config(existing_config, new_config)
if existing_config
# save config only if reconcile flag is false and existing configs value does not match default value
save_as_new_config(new_config) if !@reconcile_only_new && existing_config.value != new_config[:value]
else
save_as_new_config(new_config)
end
end
def save_as_new_config(new_config)
config = InstallationConfig.new(name: new_config[:name])
config.value = new_config[:value]
config.save
end
def reconcile_feature_config
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
if config
return false if config.value.to_s == account_features.to_s
compare_and_save(config)
else
save_as_new_config({ name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS', value: account_features })
end
end
def compare_and_save_feature(config)
features = if @reconcile_only_new
# leave the existing feature flag values as it is and add new feature flags with default values
(account_features + config.value).uniq { |h| h['name'] }
else
# update the existing feature flag values with default values and add new feature flags with default values
(config.value + account_features).uniq { |h| h['name'] }
end
save_as_new_config({ name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS', value: features })
end
end

View file

@ -0,0 +1,46 @@
require 'rails_helper'
describe ConfigLoader do
subject(:trigger) { described_class.new.process }
describe 'execute' do
context 'when called with default options' do
it 'creates installation configs' do
expect(InstallationConfig.count).to eq(0)
subject
expect(InstallationConfig.count).to be > 0
end
it 'creates account level feature defaults as entry on config table' do
subject
expect(InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')).to be_truthy
end
end
context 'with reconcile_only_new option' do
let(:class_instance) { described_class.new }
let(:config) { { name: 'WHO', value: 'corona' } }
let(:updated_config) { { name: 'WHO', value: 'covid 19' } }
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:general_configs).and_return([config])
described_class.new.process
end
it 'being true it should not update existing config value' do
expect(InstallationConfig.find_by(name: 'WHO').value).to eq('corona')
allow(class_instance).to receive(:general_configs).and_return([updated_config])
described_class.new.process({ reconcile_only_new: true })
expect(InstallationConfig.find_by(name: 'WHO').value).to eq('corona')
end
it 'updates the existing config value with new default value' do
expect(InstallationConfig.find_by(name: 'WHO').value).to eq('corona')
allow(class_instance).to receive(:general_configs).and_return([updated_config])
described_class.new.process({ reconcile_only_new: false })
expect(InstallationConfig.find_by(name: 'WHO').value).to eq('covid 19')
end
end
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe InstallationConfig do
it { is_expected.to validate_presence_of(:name) }
end