diff --git a/app/config.py b/app/config.py index 6da1388c..e29f9ae4 100644 --- a/app/config.py +++ b/app/config.py @@ -1429,6 +1429,7 @@ class Settings(BaseSettings): settings = Settings() +ENV_OVERRIDE_KEYS = set(settings.model_fields_set) _PERIOD_PRICE_FIELDS: Dict[int, str] = { 14: "PRICE_14_DAYS", diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 34a74bae..e5cc6efe 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -9,7 +9,13 @@ from app.database.universal_migration import ensure_default_web_api_token from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import Settings, settings, refresh_period_prices, refresh_traffic_prices +from app.config import ( + Settings, + settings, + refresh_period_prices, + refresh_traffic_prices, + ENV_OVERRIDE_KEYS, +) from app.database.crud.system_setting import ( delete_system_setting, upsert_system_setting, @@ -631,6 +637,10 @@ class BotConfigurationService: def is_read_only(cls, key: str) -> bool: return key in cls.READ_ONLY_KEYS + @classmethod + def _is_env_override(cls, key: str) -> bool: + return key in cls._env_override_keys + @classmethod def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]: if isinstance(value, bool): @@ -744,6 +754,7 @@ class BotConfigurationService: _definitions: Dict[str, SettingDefinition] = {} _original_values: Dict[str, Any] = settings.model_dump() _overrides_raw: Dict[str, Optional[str]] = {} + _env_override_keys: set[str] = set(ENV_OVERRIDE_KEYS) _callback_tokens: Dict[str, str] = {} _token_to_key: Dict[str, str] = {} _choice_tokens: Dict[str, Dict[Any, str]] = {} @@ -867,6 +878,8 @@ class BotConfigurationService: @classmethod def has_override(cls, key: str) -> bool: + if cls._is_env_override(key): + return False return key in cls._overrides_raw @classmethod @@ -1162,6 +1175,12 @@ class BotConfigurationService: overrides[row.key] = row.value for key, raw_value in overrides.items(): + if cls._is_env_override(key): + logger.debug( + "Пропускаем настройку %s из БД: используется значение из окружения", + key, + ) + continue try: parsed_value = cls.deserialize_value(key, raw_value) except Exception as error: @@ -1281,8 +1300,15 @@ class BotConfigurationService: raw_value = cls.serialize_value(key, value) await upsert_system_setting(db, key, raw_value) - cls._overrides_raw[key] = raw_value - cls._apply_to_settings(key, value) + if cls._is_env_override(key): + logger.info( + "Настройка %s сохранена в БД, но не применена: значение задаётся через окружение", + key, + ) + cls._overrides_raw.pop(key, None) + else: + cls._overrides_raw[key] = raw_value + cls._apply_to_settings(key, value) if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}: await cls._sync_default_web_api_token() @@ -1300,14 +1326,26 @@ class BotConfigurationService: await delete_system_setting(db, key) cls._overrides_raw.pop(key, None) - original = cls.get_original_value(key) - cls._apply_to_settings(key, original) + if cls._is_env_override(key): + logger.info( + "Настройка %s сброшена в БД, используется значение из окружения", + key, + ) + else: + original = cls.get_original_value(key) + cls._apply_to_settings(key, original) if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}: await cls._sync_default_web_api_token() @classmethod def _apply_to_settings(cls, key: str, value: Any) -> None: + if cls._is_env_override(key): + logger.debug( + "Пропуск применения настройки %s: значение задано через окружение", + key, + ) + return try: setattr(settings, key, value) if key in { diff --git a/tests/services/test_system_settings_env_priority.py b/tests/services/test_system_settings_env_priority.py new file mode 100644 index 00000000..81ed9104 --- /dev/null +++ b/tests/services/test_system_settings_env_priority.py @@ -0,0 +1,163 @@ +from types import SimpleNamespace +from pathlib import Path +import sys + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.config import settings +from app.services.system_settings_service import bot_configuration_service + + +@pytest.mark.asyncio +async def test_env_override_prevents_set_value(monkeypatch): + bot_configuration_service.initialize_definitions() + + env_value = "env_support" + monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value) + original_values = dict(bot_configuration_service._original_values) + original_values["SUPPORT_USERNAME"] = env_value + monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + + env_keys = set(bot_configuration_service._env_override_keys) + env_keys.add("SUPPORT_USERNAME") + monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys) + monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {}) + + async def fake_upsert(db, key, value, description=None): # noqa: ANN001 + return None + + monkeypatch.setattr( + "app.services.system_settings_service.upsert_system_setting", + fake_upsert, + ) + + await bot_configuration_service.set_value( + object(), + "SUPPORT_USERNAME", + "db_support", + ) + + assert settings.SUPPORT_USERNAME == env_value + assert not bot_configuration_service.has_override("SUPPORT_USERNAME") + + +@pytest.mark.asyncio +async def test_env_override_prevents_reset_value(monkeypatch): + bot_configuration_service.initialize_definitions() + + env_value = "env_support" + monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value) + original_values = dict(bot_configuration_service._original_values) + original_values["SUPPORT_USERNAME"] = env_value + monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + + env_keys = set(bot_configuration_service._env_override_keys) + env_keys.add("SUPPORT_USERNAME") + monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys) + monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {"SUPPORT_USERNAME": "db"}) + + async def fake_delete(db, key): # noqa: ANN001 + return None + + monkeypatch.setattr( + "app.services.system_settings_service.delete_system_setting", + fake_delete, + ) + + await bot_configuration_service.reset_value( + object(), + "SUPPORT_USERNAME", + ) + + assert settings.SUPPORT_USERNAME == env_value + assert not bot_configuration_service.has_override("SUPPORT_USERNAME") + + +@pytest.mark.asyncio +async def test_initialize_skips_db_value_for_env_override(monkeypatch): + bot_configuration_service.initialize_definitions() + + env_value = "env_support" + monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value) + original_values = dict(bot_configuration_service._original_values) + original_values["SUPPORT_USERNAME"] = env_value + monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + + env_keys = set(bot_configuration_service._env_override_keys) + env_keys.add("SUPPORT_USERNAME") + monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys) + monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {}) + + class DummyResult: + def scalars(self): + return self + + def all(self): + return [SimpleNamespace(key="SUPPORT_USERNAME", value="db_support")] + + class DummySession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): # noqa: ANN001 + return False + + async def execute(self, query): # noqa: ANN001 + return DummyResult() + + monkeypatch.setattr( + "app.services.system_settings_service.AsyncSessionLocal", + lambda: DummySession(), + ) + + async def fake_sync(): + return True + + monkeypatch.setattr( + "app.services.system_settings_service.ensure_default_web_api_token", + fake_sync, + raising=False, + ) + + await bot_configuration_service.initialize() + + assert settings.SUPPORT_USERNAME == env_value + assert "SUPPORT_USERNAME" not in bot_configuration_service._overrides_raw + assert not bot_configuration_service.has_override("SUPPORT_USERNAME") + + +@pytest.mark.asyncio +async def test_set_value_applies_without_env_override(monkeypatch): + bot_configuration_service.initialize_definitions() + + monkeypatch.setattr(bot_configuration_service, "_env_override_keys", set()) + monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {}) + + initial_value = True + target_value = False + + monkeypatch.setattr(settings, "SUPPORT_MENU_ENABLED", initial_value) + original_values = dict(bot_configuration_service._original_values) + original_values["SUPPORT_MENU_ENABLED"] = initial_value + monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + + async def fake_upsert(db, key, value, description=None): # noqa: ANN001 + return None + + monkeypatch.setattr( + "app.services.system_settings_service.upsert_system_setting", + fake_upsert, + ) + + await bot_configuration_service.set_value( + object(), + "SUPPORT_MENU_ENABLED", + target_value, + ) + + assert settings.SUPPORT_MENU_ENABLED is target_value + assert bot_configuration_service.has_override("SUPPORT_MENU_ENABLED")