"""AppConfig service — typed access to structured application settings. Replaces the key-value system_settings table with JSONB-column app_config. Both old and new APIs are supported during migration; system_settings is kept as a read-only backward-compat layer. Usage: from app.core.config_service import get_app_config, update_render_config # Async (FastAPI handlers) config = await get_app_config(db) config.render.thumbnail_renderer # → "blender" # Sync (Celery tasks) from app.core.config_service import get_app_config_sync config = get_app_config_sync() """ import json import logging from datetime import datetime from typing import Any from pydantic import BaseModel, Field from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Typed config models # --------------------------------------------------------------------------- class RenderConfig(BaseModel): thumbnail_renderer: str = "blender" blender_engine: str = "cycles" blender_cycles_samples: int = 256 blender_eevee_samples: int = 64 thumbnail_format: str = "jpg" blender_smooth_angle: int = 30 cycles_device: str = "gpu" render_backend: str = "celery" product_thumbnail_priority: list[str] = Field( default_factory=lambda: ["latest_render", "cad_thumbnail"] ) class WorkerConfig(BaseModel): concurrency: int = 8 max_concurrent_renders: int = 3 render_stall_timeout_minutes: int = 120 class StorageConfig(BaseModel): upload_dir: str = "/app/uploads" max_upload_size_mb: int = 500 class NotificationsConfig(BaseModel): pass # reserved for Phase G class BillingConfig(BaseModel): pass # reserved for Phase L class AppConfig(BaseModel): render: RenderConfig = Field(default_factory=RenderConfig) storage: StorageConfig = Field(default_factory=StorageConfig) notifications: NotificationsConfig = Field(default_factory=NotificationsConfig) worker: WorkerConfig = Field(default_factory=WorkerConfig) billing: BillingConfig = Field(default_factory=BillingConfig) updated_at: datetime | None = None # --------------------------------------------------------------------------- # DB access # --------------------------------------------------------------------------- _SELECT_SQL = "SELECT render, storage, notifications, worker, billing, updated_at FROM app_config ORDER BY version DESC LIMIT 1" _DEFAULT_CONFIG = AppConfig() async def get_app_config(db: AsyncSession) -> AppConfig: """Load AppConfig from app_config table (async).""" try: result = await db.execute(text(_SELECT_SQL)) row = result.one_or_none() return _row_to_config(row) except Exception as exc: logger.warning("get_app_config failed, returning defaults: %s", exc) return _DEFAULT_CONFIG def get_app_config_sync(session: Session | None = None) -> AppConfig: """Load AppConfig from app_config table (sync, for Celery tasks).""" try: if session: row = session.execute(text(_SELECT_SQL)).one_or_none() return _row_to_config(row) from app.config import settings as app_settings from sqlalchemy import create_engine engine = create_engine(app_settings.database_url_sync) with Session(engine) as s: row = s.execute(text(_SELECT_SQL)).one_or_none() engine.dispose() return _row_to_config(row) except Exception as exc: logger.warning("get_app_config_sync failed, returning defaults: %s", exc) return _DEFAULT_CONFIG async def update_render_config(db: AsyncSession, updates: dict[str, Any]) -> AppConfig: """Merge updates into the render section of app_config.""" return await _update_section(db, "render", updates) async def update_worker_config(db: AsyncSession, updates: dict[str, Any]) -> AppConfig: """Merge updates into the worker section of app_config.""" return await _update_section(db, "worker", updates) async def _update_section(db: AsyncSession, section: str, updates: dict[str, Any]) -> AppConfig: valid_sections = {"render", "storage", "notifications", "worker", "billing"} if section not in valid_sections: raise ValueError(f"Invalid config section: {section}") await db.execute( text(f""" UPDATE app_config SET {section} = {section} || :patch::jsonb, updated_at = NOW() """), {"patch": json.dumps(updates)}, ) await db.commit() return await get_app_config(db) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _row_to_config(row) -> AppConfig: if row is None: return _DEFAULT_CONFIG def _parse(raw, model_cls): if raw is None: return model_cls() if isinstance(raw, str): raw = json.loads(raw) try: return model_cls(**raw) except Exception: return model_cls() return AppConfig( render=_parse(row[0], RenderConfig), storage=_parse(row[1], StorageConfig), notifications=_parse(row[2], NotificationsConfig), worker=_parse(row[3], WorkerConfig), billing=_parse(row[4], BillingConfig), updated_at=row[5], )