From 905c93b8f800cb51d919f5372f9f6074cbe0e4a1 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Sun, 10 May 2020 22:40:36 +0530 Subject: [PATCH] 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) --- .rubocop.yml | 2 + app/models/account.rb | 2 +- .../concerns/{features.rb => featurable.rb} | 7 +- app/models/installation_config.rb | 31 +++++++ config/installation_config.yml | 2 + ...200510112339_create_installation_config.rb | 13 +++ db/schema.rb | 24 ++---- db/seeds.rb | 3 + lib/config_loader.rb | 84 +++++++++++++++++++ spec/lib/config_loader_spec.rb | 46 ++++++++++ spec/models/installation_config_spec.rb | 7 ++ 11 files changed, 203 insertions(+), 18 deletions(-) rename app/models/concerns/{features.rb => featurable.rb} (84%) create mode 100644 app/models/installation_config.rb create mode 100644 config/installation_config.yml create mode 100644 db/migrate/20200510112339_create_installation_config.rb create mode 100644 lib/config_loader.rb create mode 100644 spec/lib/config_loader_spec.rb create mode 100644 spec/models/installation_config_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 8d2405e3f..bded7aed3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -94,6 +94,8 @@ Rails/UniqueValidationWithoutIndex: Exclude: - 'app/models/channel/twitter_profile.rb' - 'app/models/webhook.rb' +RSpec/NamedSubject: + Enabled: false AllCops: Exclude: - 'bin/**/*' diff --git a/app/models/account.rb b/app/models/account.rb index 2b2845ec4..42dcf75ae 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -19,7 +19,7 @@ class Account < ApplicationRecord include Events::Types include Reportable - include Features + include Featurable DEFAULT_QUERY_SETTING = { flag_query_mode: :bit_operator diff --git a/app/models/concerns/features.rb b/app/models/concerns/featurable.rb similarity index 84% rename from app/models/concerns/features.rb rename to app/models/concerns/featurable.rb index d1ae54c5a..720f44cfa 100644 --- a/app/models/concerns/features.rb +++ b/app/models/concerns/featurable.rb @@ -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 diff --git a/app/models/installation_config.rb b/app/models/installation_config.rb new file mode 100644 index 000000000..589ab3a36 --- /dev/null +++ b/app/models/installation_config.rb @@ -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 diff --git a/config/installation_config.yml b/config/installation_config.yml new file mode 100644 index 000000000..396fe21a8 --- /dev/null +++ b/config/installation_config.yml @@ -0,0 +1,2 @@ +- name: SHOW_WIDGET_HEADER + value: true diff --git a/db/migrate/20200510112339_create_installation_config.rb b/db/migrate/20200510112339_create_installation_config.rb new file mode 100644 index 000000000..7397bb7bf --- /dev/null +++ b/db/migrate/20200510112339_create_installation_config.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 39280064a..7fab7d066 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/db/seeds.rb b/db/seeds.rb index d6df42782..1a169c966 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,3 +1,6 @@ +# loading installation configs +ConfigLoader.new.process + account = Account.create!( name: 'Acme Inc', domain: 'support.chatwoot.com', diff --git a/lib/config_loader.rb b/lib/config_loader.rb new file mode 100644 index 000000000..58abf9c21 --- /dev/null +++ b/lib/config_loader.rb @@ -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 diff --git a/spec/lib/config_loader_spec.rb b/spec/lib/config_loader_spec.rb new file mode 100644 index 000000000..60b689872 --- /dev/null +++ b/spec/lib/config_loader_spec.rb @@ -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 diff --git a/spec/models/installation_config_spec.rb b/spec/models/installation_config_spec.rb new file mode 100644 index 000000000..49270f2a8 --- /dev/null +++ b/spec/models/installation_config_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe InstallationConfig do + it { is_expected.to validate_presence_of(:name) } +end