mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* fix(hosting): consistent provider-block visibility + fix Twelve Data toggle bug (#3089) T-Invest was the only provider block always rendered regardless of its checkbox state; now it follows the same pattern as every other provider (shown when tinkoff_invest or moex_public is enabled, since T-Invest also serves as a brand-logo fallback for MOEX-priced securities). Also fixes a related functional bug: unchecking every securities provider tried to clear the legacy securities_provider setting by assigning nil, but rails-settings-cached treats nil as "delete override", which silently reverted the field to its own default ("twelve_data") — re-enabling Twelve Data right after the user disabled it. Assigning "" instead persists the cleared state. Twelve Data and Yahoo Finance settings blocks can still be shown purely because they're the selected FX/exchange-rate provider even when unchecked for securities pricing; added an info notice explaining that instead of leaving it unexplained. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(hosting): address review feedback on #3333 - Drop the FX-only notice for Twelve Data/Yahoo Finance per feedback — the block staying visible while unchecked (because it's still the FX provider) doesn't need extra UI explanation. - Fix Codex finding: T-Invest settings must stay visible/manageable whenever a token is already configured, not just when tinkoff_invest or moex_public is checked. Security::Provided#import_brand_logo calls the T-Invest provider unconditionally for every non-crypto security once a token exists, regardless of price provider — hiding the field in that case would leave an active credential impossible to see, rotate, or clear through the UI. Reworded the notice to reflect the real, provider-independent reason instead of the narrower "MOEX only" framing. - Fix CodeRabbit finding: setting_test.rb's default-fallback test now isolates against SECURITIES_PROVIDER(S) env vars, and the explicit-clear test captures and restores the pre-test values instead of hardcoding a restore target. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * No overexplaining in code --------- Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Juan José Mata <jjmata@jjmata.com>
319 lines
14 KiB
Ruby
319 lines
14 KiB
Ruby
class Settings::HostingsController < ApplicationController
|
|
layout "settings"
|
|
|
|
# Minimum accepted value for each configurable numeric LLM field. Mirrors the
|
|
# `min:` attribute on the form inputs in `_openai_settings.html.erb` so the
|
|
# controller rejects what the browser-side validator would reject.
|
|
LLM_NUMERIC_MINIMUMS = {
|
|
llm_context_window: 256,
|
|
llm_max_response_tokens: 64,
|
|
llm_max_items_per_call: 1,
|
|
openai_request_timeout: Provider::Openai::MIN_REQUEST_TIMEOUT,
|
|
ai_response_timeout: Chat::MIN_RESPONSE_TIMEOUT.to_i
|
|
}.freeze
|
|
|
|
guard_feature unless: -> { self_hosted? }
|
|
|
|
before_action :ensure_admin, only: [ :update, :clear_cache, :disconnect_external_assistant ]
|
|
before_action :ensure_super_admin_for_onboarding, only: :update
|
|
|
|
def show
|
|
@breadcrumbs = [
|
|
[ t("breadcrumbs.home"), root_path ],
|
|
[ t("breadcrumbs.self_hosting"), nil ]
|
|
]
|
|
|
|
# Determine which providers are currently selected
|
|
exchange_rate_provider = ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider
|
|
enabled_securities = Setting.enabled_securities_providers
|
|
|
|
# Show provider settings if used for FX or enabled for securities
|
|
@show_twelve_data_settings = exchange_rate_provider == "twelve_data" || enabled_securities.include?("twelve_data")
|
|
@show_yahoo_finance_settings = exchange_rate_provider == "yahoo_finance" || enabled_securities.include?("yahoo_finance")
|
|
@show_tiingo_settings = enabled_securities.include?("tiingo")
|
|
@show_eodhd_settings = enabled_securities.include?("eodhd")
|
|
@show_alpha_vantage_settings = enabled_securities.include?("alpha_vantage")
|
|
tinkoff_invest_checked = enabled_securities.include?("tinkoff_invest")
|
|
tinkoff_invest_configured = ENV["TINKOFF_INVEST_API_KEY"].present? || Setting.tinkoff_invest_api_key.present?
|
|
@show_tinkoff_invest_settings = tinkoff_invest_checked || enabled_securities.include?("moex_public") || tinkoff_invest_configured
|
|
@tinkoff_invest_moex_only = @show_tinkoff_invest_settings && !tinkoff_invest_checked
|
|
|
|
# Only fetch provider data if we're showing the section
|
|
if @show_twelve_data_settings
|
|
twelve_data_provider = Provider::Registry.get_provider(:twelve_data)
|
|
@twelve_data_usage = twelve_data_provider&.usage
|
|
@plan_restricted_securities = Current.family.securities_with_plan_restrictions(provider: "TwelveData")
|
|
end
|
|
|
|
if @show_yahoo_finance_settings
|
|
@yahoo_finance_provider = Provider::Registry.get_provider(:yahoo_finance)
|
|
@yahoo_finance_health_status = @yahoo_finance_provider&.health_status || :unknown
|
|
end
|
|
|
|
# Property valuation (AVM) providers — usage is shown against their tight
|
|
# monthly request caps when a key is configured
|
|
@rentcast_usage = Provider::Registry.get_provider(:rentcast)&.usage
|
|
@realie_usage = Provider::Registry.get_provider(:realie)&.usage
|
|
end
|
|
|
|
def update
|
|
if hosting_params.key?(:onboarding_state)
|
|
onboarding_state = hosting_params[:onboarding_state].to_s
|
|
Setting.onboarding_state = onboarding_state
|
|
end
|
|
|
|
if hosting_params.key?(:require_email_confirmation)
|
|
Setting.require_email_confirmation = hosting_params[:require_email_confirmation]
|
|
end
|
|
|
|
if hosting_params.key?(:invite_only_default_family_id)
|
|
value = hosting_params[:invite_only_default_family_id].presence
|
|
Setting.invite_only_default_family_id = value
|
|
end
|
|
|
|
if hosting_params.key?(:brand_fetch_client_id)
|
|
Setting.brand_fetch_client_id = hosting_params[:brand_fetch_client_id]
|
|
end
|
|
|
|
if hosting_params.key?(:brand_fetch_high_res_logos)
|
|
Setting.brand_fetch_high_res_logos = hosting_params[:brand_fetch_high_res_logos] == "1"
|
|
end
|
|
|
|
update_encrypted_setting(:twelve_data_api_key)
|
|
|
|
if hosting_params.key?(:exchange_rate_provider)
|
|
Setting.exchange_rate_provider = hosting_params[:exchange_rate_provider]
|
|
end
|
|
|
|
if hosting_params.key?(:securities_provider)
|
|
Setting.securities_provider = hosting_params[:securities_provider]
|
|
end
|
|
|
|
if hosting_params.key?(:securities_providers)
|
|
new_providers = Array(hosting_params[:securities_providers]).reject(&:blank?) & Security.valid_price_providers
|
|
old_providers = Setting.enabled_securities_providers
|
|
|
|
Setting.securities_providers = new_providers.join(",")
|
|
|
|
Setting.securities_provider = "" if new_providers.empty?
|
|
|
|
# Mark securities linked to removed providers as offline so they aren't
|
|
# silently queried against an incompatible fallback provider (e.g. MFAPI
|
|
# scheme codes sent to TwelveData). The price_provider is preserved so
|
|
# provider_status can report :provider_unavailable.
|
|
removed = old_providers - new_providers
|
|
removed.each do |removed_provider|
|
|
Security.where(price_provider: removed_provider, offline: false)
|
|
.in_batches.update_all(offline: true, offline_reason: "provider_disabled")
|
|
end
|
|
|
|
# Bring securities back online when their provider is re-enabled — but only
|
|
# those that were taken offline by a provider toggle, not by health checks.
|
|
added = new_providers - old_providers
|
|
added.each do |added_provider|
|
|
Security.where(price_provider: added_provider, offline: true, offline_reason: "provider_disabled")
|
|
.in_batches.update_all(offline: false, offline_reason: nil, failed_fetch_count: 0, failed_fetch_at: nil)
|
|
end
|
|
end
|
|
|
|
update_encrypted_setting(:tiingo_api_key)
|
|
update_encrypted_setting(:eodhd_api_key)
|
|
update_encrypted_setting(:alpha_vantage_api_key)
|
|
update_encrypted_setting(:tinkoff_invest_api_key)
|
|
update_encrypted_setting(:rentcast_api_key)
|
|
update_encrypted_setting(:realie_api_key)
|
|
|
|
if hosting_params.key?(:syncs_include_pending)
|
|
Setting.syncs_include_pending = hosting_params[:syncs_include_pending] == "1"
|
|
end
|
|
|
|
sync_settings_changed = false
|
|
|
|
if hosting_params.key?(:auto_sync_enabled)
|
|
Setting.auto_sync_enabled = hosting_params[:auto_sync_enabled] == "1"
|
|
sync_settings_changed = true
|
|
end
|
|
|
|
if hosting_params.key?(:auto_sync_time)
|
|
time_value = hosting_params[:auto_sync_time]
|
|
unless Setting.valid_auto_sync_time?(time_value)
|
|
flash[:alert] = t(".invalid_sync_time")
|
|
return redirect_to settings_hosting_path
|
|
end
|
|
|
|
Setting.auto_sync_time = time_value
|
|
Setting.auto_sync_timezone = current_user_timezone
|
|
sync_settings_changed = true
|
|
end
|
|
|
|
if sync_settings_changed
|
|
sync_auto_sync_scheduler!
|
|
end
|
|
|
|
update_encrypted_setting(:openai_access_token)
|
|
|
|
# Validate OpenAI configuration before updating
|
|
if hosting_params.key?(:openai_uri_base) || hosting_params.key?(:openai_model)
|
|
Setting.validate_openai_config!(
|
|
uri_base: hosting_params[:openai_uri_base],
|
|
model: hosting_params[:openai_model]
|
|
)
|
|
end
|
|
|
|
if hosting_params.key?(:openai_uri_base)
|
|
Setting.openai_uri_base = hosting_params[:openai_uri_base]
|
|
end
|
|
|
|
if hosting_params.key?(:openai_model)
|
|
Setting.openai_model = hosting_params[:openai_model]
|
|
end
|
|
|
|
if hosting_params.key?(:openai_json_mode)
|
|
Setting.openai_json_mode = hosting_params[:openai_json_mode].presence
|
|
end
|
|
|
|
update_encrypted_setting(:anthropic_access_token)
|
|
|
|
if hosting_params.key?(:anthropic_base_url)
|
|
raw_base_url = hosting_params[:anthropic_base_url].to_s.strip
|
|
if raw_base_url.blank?
|
|
Setting.anthropic_base_url = nil
|
|
else
|
|
parsed = URI.parse(raw_base_url) rescue nil
|
|
unless parsed.is_a?(URI::HTTP)
|
|
raise Setting::ValidationError, t(".invalid_anthropic_base_url")
|
|
end
|
|
# A custom Anthropic-compatible endpoint requires a model — Provider::Anthropic
|
|
# raises without one. Validate the pair together (mirrors the OpenAI branch), using
|
|
# the submitted model when present so a blanked model field is caught too.
|
|
effective_model =
|
|
if hosting_params.key?(:anthropic_model)
|
|
hosting_params[:anthropic_model].to_s.strip
|
|
else
|
|
Setting.anthropic_model.to_s.strip
|
|
end
|
|
if effective_model.blank?
|
|
raise Setting::ValidationError, t(".anthropic_model_required_for_base_url")
|
|
end
|
|
Setting.anthropic_base_url = raw_base_url
|
|
end
|
|
end
|
|
|
|
if hosting_params.key?(:anthropic_model)
|
|
Setting.anthropic_model = hosting_params[:anthropic_model].presence
|
|
end
|
|
|
|
if hosting_params.key?(:llm_provider)
|
|
provider = hosting_params[:llm_provider].to_s
|
|
if %w[openai anthropic].include?(provider)
|
|
Setting.llm_provider = provider
|
|
end
|
|
end
|
|
|
|
LLM_NUMERIC_MINIMUMS.each do |key, minimum|
|
|
next unless hosting_params.key?(key)
|
|
raw = hosting_params[key].to_s.strip
|
|
if raw.blank?
|
|
Setting.public_send("#{key}=", nil)
|
|
next
|
|
end
|
|
parsed = Integer(raw, 10) rescue nil
|
|
if parsed.nil? || parsed < minimum
|
|
label = t("settings.hostings.openai_settings.#{key}_label")
|
|
raise Setting::ValidationError, t(".invalid_llm_budget", field: label, minimum: minimum)
|
|
end
|
|
Setting.public_send("#{key}=", parsed)
|
|
end
|
|
|
|
if hosting_params.key?(:external_assistant_url)
|
|
Setting.external_assistant_url = hosting_params[:external_assistant_url]
|
|
end
|
|
|
|
update_encrypted_setting(:external_assistant_token)
|
|
|
|
if hosting_params.key?(:external_assistant_agent_id)
|
|
Setting.external_assistant_agent_id = hosting_params[:external_assistant_agent_id]
|
|
end
|
|
|
|
update_assistant_type
|
|
|
|
redirect_to settings_hosting_path, notice: t(".success")
|
|
rescue Setting::ValidationError => error
|
|
# Preserve user-submitted OpenAI config so the form re-renders with their
|
|
# input intact (issue #1824). The form auto-submits on blur, so a partial
|
|
# entry (e.g. URI base before model) hits validation and would otherwise
|
|
# be wiped because the view reads from the unchanged Setting.* values.
|
|
@openai_uri_base_input = hosting_params[:openai_uri_base] if hosting_params.key?(:openai_uri_base)
|
|
@openai_model_input = hosting_params[:openai_model] if hosting_params.key?(:openai_model)
|
|
@anthropic_base_url_input = hosting_params[:anthropic_base_url] if hosting_params.key?(:anthropic_base_url)
|
|
@anthropic_model_input = hosting_params[:anthropic_model] if hosting_params.key?(:anthropic_model)
|
|
flash.now[:alert] = error.message
|
|
render :show, status: :unprocessable_entity
|
|
end
|
|
|
|
def clear_cache
|
|
DataCacheClearJob.perform_later(Current.family)
|
|
redirect_to settings_hosting_path, notice: t(".cache_cleared")
|
|
end
|
|
|
|
def disconnect_external_assistant
|
|
Setting.external_assistant_url = nil
|
|
Setting.external_assistant_token = nil
|
|
Setting.external_assistant_agent_id = nil
|
|
Current.family.update!(assistant_type: "builtin") unless ENV["ASSISTANT_TYPE"].present?
|
|
redirect_to settings_hosting_path, notice: t(".external_assistant_disconnected")
|
|
rescue => e
|
|
Rails.logger.error("[External Assistant] Disconnect failed: #{e.message}")
|
|
redirect_to settings_hosting_path, alert: t("settings.hostings.update.failure")
|
|
end
|
|
|
|
private
|
|
# Strong parameters for the self-hosting settings form.
|
|
def hosting_params
|
|
return ActionController::Parameters.new unless params.key?(:setting)
|
|
params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :tiingo_api_key, :eodhd_api_key, :alpha_vantage_api_key, :tinkoff_invest_api_key, :rentcast_api_key, :realie_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :anthropic_access_token, :anthropic_base_url, :anthropic_model, :llm_provider, :llm_context_window, :llm_max_response_tokens, :llm_max_items_per_call, :openai_request_timeout, :ai_response_timeout, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id, securities_providers: [])
|
|
end
|
|
|
|
def update_assistant_type
|
|
return unless params[:family].present? && params[:family][:assistant_type].present?
|
|
return if ENV["ASSISTANT_TYPE"].present?
|
|
|
|
assistant_type = params[:family][:assistant_type]
|
|
Current.family.update!(assistant_type: assistant_type) if Family::ASSISTANT_TYPES.include?(assistant_type)
|
|
end
|
|
|
|
def ensure_admin
|
|
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin?
|
|
end
|
|
|
|
def ensure_super_admin_for_onboarding
|
|
onboarding_params = %i[onboarding_state invite_only_default_family_id]
|
|
return unless onboarding_params.any? { |p| hosting_params.key?(p) }
|
|
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.super_admin?
|
|
end
|
|
|
|
def sync_auto_sync_scheduler!
|
|
AutoSyncScheduler.sync!
|
|
rescue StandardError => error
|
|
Rails.logger.error("[AutoSyncScheduler] Failed to sync scheduler: #{error.message}")
|
|
Rails.logger.error(error.backtrace.join("\n"))
|
|
flash[:alert] = t(".scheduler_sync_failed")
|
|
end
|
|
|
|
def update_encrypted_setting(param_key)
|
|
return unless hosting_params.key?(param_key)
|
|
value = hosting_params[param_key].to_s.strip
|
|
|
|
# "********" is the masked placeholder rendered for an existing key; it
|
|
# means "leave the stored value untouched". A blank submission, however,
|
|
# is an explicit request to clear the key, so persist nil in that case.
|
|
return if value == "********"
|
|
|
|
Setting.public_send(:"#{param_key}=", value.presence)
|
|
end
|
|
|
|
def current_user_timezone
|
|
Current.family&.timezone.presence || "UTC"
|
|
end
|
|
end
|