Files
HartOMat/backend/app/core/config_service.py
T
Hartmut 409fb92899 feat(P2): USD Foundation — canonical part identity + material overrides
M1 — USD exporter:
- render-worker/scripts/export_step_to_usd.py (631 lines)
  Full XCAF traversal, one UsdGeom.Mesh per leaf part,
  schaeffler:partKey on every prim, index-space sharpEdgeVertexPairs
- render-worker/Dockerfile: usd-core>=24.11 installed (USD 0.26.3)

M2 — usd_master MediaAsset + pipeline auto-chain:
- migrations 060 (usd_master enum), 061 (3 JSONB columns),
  062 (rename tessellation settings keys)
- generate_usd_master_task: runs export_step_to_usd.py, upserts
  usd_master MediaAsset, writes resolved_material_assignments to CadFile
- Auto-chained from generate_gltf_geometry_task after every GLB export
- step_tasks.py shim re-exports generate_usd_master_task

M3 — scene-manifest API:
- part_key_service.py: build_scene_manifest(), generate_part_key(),
  four-layer material priority resolution with provenance
- SceneManifest / PartEntry Pydantic models in products/schemas.py
- GET /api/cad/{id}/scene-manifest endpoint (graceful fallback to
  parsed_objects when USD not yet generated)
- POST /api/cad/{id}/generate-usd-master endpoint
- frontend/src/api/sceneManifest.ts: fetchSceneManifest(),
  triggerUsdMasterGeneration()

M4 — manual-material-overrides API:
- GET/PUT /api/cad/{id}/manual-material-overrides endpoints
- CadFile.manual_material_overrides JSONB column (migration 061)
- getManualOverrides() / saveManualOverrides() in cad.ts

M5 — ThreeDViewer partKey integration:
- export_step_to_gltf.py injects partKeyMap into GLB extras
- ThreeDViewer: partKeyMap extraction, resolvePartKey(), effectiveMaterials
  merges legacy partMaterials + new manualOverrides (server-side persistence)
- MaterialPanel: dual-path save (partKey vs legacy), provenance badge,
  reconciliation panel for unmatched/unassigned parts

Also:
- Admin.tsx: generate-missing-usd-masters + canonical scenes bulk actions
- ProductDetail.tsx: usd_master row in asset table
- vite-env.d.ts: fix ImportMeta.env TypeScript error
- GPUProbeResult: add timestamp/devices/render_time_s fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 13:11:09 +01:00

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 = "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],
)