feat: Implement LLM provider deletion and refactor ConfigManager to preserve unknown payload sections.

This commit is contained in:
2026-03-14 09:19:08 +03:00
parent feb07bf366
commit 3928455189
9 changed files with 256 additions and 9 deletions

View File

@@ -0,0 +1,53 @@
# [DEF:backend.src.core.__tests__.test_config_manager_compat:Module]
# @TIER: STANDARD
# @SEMANTICS: config-manager, compatibility, payload, tests
# @PURPOSE: Verifies ConfigManager compatibility wrappers preserve legacy payload sections.
# @LAYER: Domain
# @RELATION: VERIFIES -> ConfigManager
from src.core.config_manager import ConfigManager
from src.core.config_models import AppConfig, GlobalSettings
# [DEF:test_get_payload_preserves_legacy_sections:Function]
# @PURPOSE: Ensure get_payload merges typed config into raw payload without dropping legacy sections.
def test_get_payload_preserves_legacy_sections():
manager = ConfigManager.__new__(ConfigManager)
manager.raw_payload = {"notifications": {"smtp": {"host": "mail.local"}}}
manager.config = AppConfig(environments=[], settings=GlobalSettings())
payload = manager.get_payload()
assert payload["settings"]["migration_sync_cron"] == "0 2 * * *"
assert payload["notifications"]["smtp"]["host"] == "mail.local"
# [/DEF:test_get_payload_preserves_legacy_sections:Function]
# [DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function]
# @PURPOSE: Ensure save_config accepts raw dict payload, refreshes typed config, and preserves extra sections.
def test_save_config_accepts_raw_payload_and_keeps_extras(monkeypatch):
manager = ConfigManager.__new__(ConfigManager)
manager.raw_payload = {}
manager.config = AppConfig(environments=[], settings=GlobalSettings())
persisted = {}
def _capture_save(config, session=None):
persisted["payload"] = manager.get_payload()
monkeypatch.setattr(manager, "_save_config_to_db", _capture_save)
manager.save_config(
{
"environments": [],
"settings": GlobalSettings().model_dump(),
"notifications": {"telegram": {"bot_token": "secret"}},
}
)
assert manager.raw_payload["notifications"]["telegram"]["bot_token"] == "secret"
assert manager.config.settings.migration_sync_cron == "0 2 * * *"
assert persisted["payload"]["notifications"]["telegram"]["bot_token"] == "secret"
# [/DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function]
# [/DEF:backend.src.core.__tests__.test_config_manager_compat:Module]

View File

@@ -15,7 +15,7 @@
import json
import os
from pathlib import Path
from typing import Optional, List
from typing import Any, Optional, List
from sqlalchemy.orm import Session
@@ -44,6 +44,7 @@ class ConfigManager:
logger.reason(f"Initializing ConfigManager with legacy path: {config_path}")
self.config_path = Path(config_path)
self.raw_payload: dict[str, Any] = {}
self.config: AppConfig = self._load_config()
configure_logger(self.config.settings.logging)
@@ -69,6 +70,20 @@ class ConfigManager:
)
# [/DEF:_default_config:Function]
# [DEF:_sync_raw_payload_from_config:Function]
# @PURPOSE: Merge typed AppConfig state into raw payload while preserving unsupported legacy sections.
# @PRE: self.config is initialized as AppConfig.
# @POST: self.raw_payload contains AppConfig fields refreshed from self.config.
# @SIDE_EFFECT: Mutates self.raw_payload in memory.
# @DATA_CONTRACT: Input(None) -> Output(dict[str, Any])
def _sync_raw_payload_from_config(self) -> dict[str, Any]:
with belief_scope("ConfigManager._sync_raw_payload_from_config"):
payload = dict(self.raw_payload) if isinstance(self.raw_payload, dict) else {}
payload.update(self.config.model_dump())
self.raw_payload = payload
return payload
# [/DEF:_sync_raw_payload_from_config:Function]
# [DEF:_load_from_legacy_file:Function]
# @PURPOSE: Load legacy JSON configuration for migration fallback path.
# @PRE: self.config_path is initialized.
@@ -79,15 +94,18 @@ class ConfigManager:
with belief_scope("_load_from_legacy_file"):
if not self.config_path.exists():
logger.info("[_load_from_legacy_file][Action] Legacy config file not found, using defaults")
self.raw_payload = self._default_config().model_dump()
return self._default_config()
try:
with open(self.config_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.raw_payload = dict(data) if isinstance(data, dict) else {}
logger.info("[_load_from_legacy_file][Coherence:OK] Legacy configuration loaded")
return AppConfig(**data)
except Exception as e:
logger.error(f"[_load_from_legacy_file][Coherence:Failed] Error loading legacy config: {e}")
self.raw_payload = self._default_config().model_dump()
return self._default_config()
# [/DEF:_load_from_legacy_file:Function]
@@ -115,6 +133,7 @@ class ConfigManager:
record = self._get_record(session)
if record and record.payload:
logger.reason("Configuration found in database")
self.raw_payload = dict(record.payload)
config = AppConfig(**record.payload)
logger.reflect("Database configuration validated")
return config
@@ -126,6 +145,7 @@ class ConfigManager:
return config
except Exception as e:
logger.explore(f"Error loading config from DB: {e}")
self.raw_payload = self._default_config().model_dump()
return self._default_config()
finally:
session.close()
@@ -146,7 +166,7 @@ class ConfigManager:
db = session or SessionLocal()
try:
record = self._get_record(db)
payload = config.model_dump()
payload = self._sync_raw_payload_from_config()
if record is None:
logger.reason("Creating new global configuration record")
record = AppConfigRecord(id="global", payload=payload)
@@ -187,6 +207,38 @@ class ConfigManager:
return self.config
# [/DEF:get_config:Function]
# [DEF:get_payload:Function]
# @PURPOSE: Return full persisted payload including sections outside typed AppConfig schema.
# @PRE: Manager state is initialized.
# @POST: Returns dict payload with current AppConfig fields synchronized.
# @SIDE_EFFECT: Refreshes self.raw_payload from self.config before returning.
# @DATA_CONTRACT: Input(None) -> Output(dict[str, Any])
def get_payload(self) -> dict[str, Any]:
with belief_scope("ConfigManager.get_payload"):
return dict(self._sync_raw_payload_from_config())
# [/DEF:get_payload:Function]
# [DEF:save_config:Function]
# @PURPOSE: Persist configuration provided either as typed AppConfig or raw payload dict.
# @PRE: config is AppConfig or dict compatible with AppConfig core schema.
# @POST: self.config and self.raw_payload are synchronized and persisted to DB.
# @SIDE_EFFECT: Mutates in-memory config state, writes database record, may reconfigure logger.
# @DATA_CONTRACT: Input(AppConfig|dict[str, Any]) -> Output(None)
def save_config(self, config: AppConfig | dict[str, Any]):
with belief_scope("ConfigManager.save_config"):
if isinstance(config, AppConfig):
self.config = config
self.raw_payload = dict(config.model_dump())
elif isinstance(config, dict):
self.raw_payload = dict(config)
self.config = AppConfig(**config)
else:
raise TypeError("config must be an AppConfig instance or dict payload")
self._save_config_to_db(self.config)
configure_logger(self.config.settings.logging)
# [/DEF:save_config:Function]
# [DEF:update_global_settings:Function]
# @PURPOSE: Replace global settings and persist the resulting configuration.
# @PRE: settings is GlobalSettings.