"""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')