feat(A5): add app_config table + typed config service
- Migration 034: creates app_config table with JSONB columns (render, storage, worker, notifications, billing); migrates existing system_settings values - backend/app/core/config_service.py: - Typed Pydantic models: RenderConfig, WorkerConfig, StorageConfig, etc. - AppConfig aggregate model - get_app_config(db) async + get_app_config_sync() for Celery tasks - update_render_config() / update_worker_config() for partial updates - system_settings table preserved for backward compat during Phase B migration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
"""Add app_config table (structured settings to replace key-value system_settings).
|
||||
|
||||
app_config stores settings grouped in JSONB columns:
|
||||
- render: thumbnail_renderer, engine, samples, stl_quality, etc.
|
||||
- storage: upload_dir, minio_url, max_upload_size_mb, etc.
|
||||
- notifications: (reserved for Phase G)
|
||||
- worker: concurrency, max_concurrent_renders, stall_timeout_minutes
|
||||
- billing: (reserved for Phase L)
|
||||
|
||||
system_settings table is preserved for backward compatibility during migration.
|
||||
Existing values are migrated into app_config.
|
||||
|
||||
Revision ID: 034
|
||||
Revises: 033
|
||||
Create Date: 2026-03-06
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
revision = '034'
|
||||
down_revision = '033'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'app_config',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('version', sa.Integer(), nullable=False, server_default='1'),
|
||||
sa.Column('render', JSONB(), nullable=False, server_default='{}'),
|
||||
sa.Column('storage', JSONB(), nullable=False, server_default='{}'),
|
||||
sa.Column('notifications', JSONB(), nullable=False, server_default='{}'),
|
||||
sa.Column('worker', JSONB(), nullable=False, server_default='{}'),
|
||||
sa.Column('billing', JSONB(), nullable=False, server_default='{}'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False,
|
||||
server_default=sa.text('NOW()')),
|
||||
)
|
||||
|
||||
# Migrate existing system_settings into app_config render + worker columns
|
||||
op.execute("""
|
||||
INSERT INTO app_config (render, worker, updated_at)
|
||||
SELECT
|
||||
jsonb_build_object(
|
||||
'thumbnail_renderer', COALESCE(MAX(CASE WHEN key = 'thumbnail_renderer' THEN value END), 'blender'),
|
||||
'blender_engine', COALESCE(MAX(CASE WHEN key = 'blender_engine' THEN value END), 'cycles'),
|
||||
'blender_cycles_samples', COALESCE(MAX(CASE WHEN key = 'blender_cycles_samples' THEN value END), '256')::int,
|
||||
'blender_eevee_samples', COALESCE(MAX(CASE WHEN key = 'blender_eevee_samples' THEN value END), '64')::int,
|
||||
'thumbnail_format', COALESCE(MAX(CASE WHEN key = 'thumbnail_format' THEN value END), 'jpg'),
|
||||
'stl_quality', COALESCE(MAX(CASE WHEN key = 'stl_quality' THEN value END), 'low'),
|
||||
'blender_smooth_angle', COALESCE(MAX(CASE WHEN key = 'blender_smooth_angle' THEN value END), '30')::int,
|
||||
'cycles_device', COALESCE(MAX(CASE WHEN key = 'cycles_device' THEN value END), 'auto'),
|
||||
'render_backend', COALESCE(MAX(CASE WHEN key = 'render_backend' THEN value END), 'celery'),
|
||||
'product_thumbnail_priority', COALESCE(MAX(CASE WHEN key = 'product_thumbnail_priority' THEN value END), '["latest_render","cad_thumbnail"]')
|
||||
),
|
||||
jsonb_build_object(
|
||||
'concurrency', COALESCE(MAX(CASE WHEN key = 'celery_worker_concurrency' THEN value END), '8')::int,
|
||||
'max_concurrent_renders', COALESCE(MAX(CASE WHEN key = 'blender_max_concurrent_renders' THEN value END), '3')::int,
|
||||
'render_stall_timeout_minutes', COALESCE(MAX(CASE WHEN key = 'render_stall_timeout_minutes' THEN value END), '120')::int
|
||||
),
|
||||
NOW()
|
||||
FROM system_settings
|
||||
WHERE key IN (
|
||||
'thumbnail_renderer', 'blender_engine', 'blender_cycles_samples',
|
||||
'blender_eevee_samples', 'thumbnail_format', 'stl_quality',
|
||||
'blender_smooth_angle', 'cycles_device', 'render_backend',
|
||||
'product_thumbnail_priority', 'celery_worker_concurrency',
|
||||
'blender_max_concurrent_renders', 'render_stall_timeout_minutes'
|
||||
)
|
||||
HAVING COUNT(*) > 0
|
||||
""")
|
||||
|
||||
# Insert default row if no system_settings existed
|
||||
op.execute("""
|
||||
INSERT INTO app_config (render, worker, updated_at)
|
||||
SELECT
|
||||
'{"thumbnail_renderer": "blender", "blender_engine": "cycles",
|
||||
"blender_cycles_samples": 256, "blender_eevee_samples": 64,
|
||||
"thumbnail_format": "jpg", "stl_quality": "low",
|
||||
"blender_smooth_angle": 30, "cycles_device": "auto",
|
||||
"render_backend": "celery",
|
||||
"product_thumbnail_priority": ["latest_render", "cad_thumbnail"]}'::jsonb,
|
||||
'{"concurrency": 8, "max_concurrent_renders": 3, "render_stall_timeout_minutes": 120}'::jsonb,
|
||||
NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM app_config)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('app_config')
|
||||
@@ -0,0 +1,168 @@
|
||||
"""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"
|
||||
stl_quality: str = "low"
|
||||
blender_smooth_angle: int = 30
|
||||
cycles_device: str = "auto"
|
||||
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],
|
||||
)
|
||||
Reference in New Issue
Block a user