diff --git a/backend/alembic/versions/034_app_config.py b/backend/alembic/versions/034_app_config.py new file mode 100644 index 0000000..f09b962 --- /dev/null +++ b/backend/alembic/versions/034_app_config.py @@ -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') diff --git a/backend/app/core/config_service.py b/backend/app/core/config_service.py new file mode 100644 index 0000000..cd615aa --- /dev/null +++ b/backend/app/core/config_service.py @@ -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], + )