Files
sure/app/controllers/settings/hostings_controller.rb
T
ed6b8b752a fix(hosting): consistent provider-block visibility + fix Twelve Data toggle bug (#3333)
* 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>
2026-09-02 19:48:32 +02:00

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