168 lines
5.4 KiB
Python
168 lines
5.4 KiB
Python
"""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],
|
|
)
|