diff --git a/backend/app/api/routers/orders.py b/backend/app/api/routers/orders.py index c165fe9..ff07f2c 100644 --- a/backend/app/api/routers/orders.py +++ b/backend/app/api/routers/orders.py @@ -345,6 +345,7 @@ async def create_order( created_by=user.id, source_excel=body.source_excel, notes=body.notes, + tenant_id=getattr(user, 'tenant_id', None), ) db.add(order) await db.flush() @@ -364,6 +365,7 @@ async def create_order( lagertyp=item_data.lagertyp, medias_rendering=item_data.medias_rendering, components=[c.model_dump() for c in item_data.components], + tenant_id=getattr(user, 'tenant_id', None), ) db.add(item) @@ -381,6 +383,7 @@ async def create_order( render_position_id=line_data.render_position_id, gewuenschte_bildnummer=line_data.gewuenschte_bildnummer, notes=line_data.notes, + tenant_id=getattr(user, 'tenant_id', None), ) db.add(line) @@ -565,6 +568,7 @@ async def split_missing_step( created_by=order.created_by, source_excel=order.source_excel, notes=f"Split from {order.order_number} — awaiting STEP files", + tenant_id=order.tenant_id, ) db.add(new_order) await db.flush() @@ -665,6 +669,7 @@ async def generate_lines_from_items( product_id=product.id, output_type_id=type_id, gewuenschte_bildnummer=item.gewuenschte_bildnummer, + tenant_id=getattr(user, 'tenant_id', None), ) db.add(line) existing_pairs.add(pair) @@ -807,6 +812,7 @@ async def add_order_line( render_position_id=body.render_position_id, gewuenschte_bildnummer=body.gewuenschte_bildnummer, notes=body.notes, + tenant_id=getattr(user, 'tenant_id', None), ) db.add(line) try: diff --git a/backend/app/api/routers/products.py b/backend/app/api/routers/products.py index db0f10a..8c1a7a3 100644 --- a/backend/app/api/routers/products.py +++ b/backend/app/api/routers/products.py @@ -206,7 +206,7 @@ async def create_product( raise HTTPException(409, detail=f"Product with pim_id '{body.pim_id}' already exists") from app.services.product_service import create_default_positions - product = Product(**body.model_dump()) + product = Product(**body.model_dump(), tenant_id=getattr(user, 'tenant_id', None)) db.add(product) await db.flush() await create_default_positions(db, product.id) diff --git a/backend/app/api/routers/uploads.py b/backend/app/api/routers/uploads.py index 17f93cf..7222433 100644 --- a/backend/app/api/routers/uploads.py +++ b/backend/app/api/routers/uploads.py @@ -241,6 +241,7 @@ async def finalize_excel( included_rows, source_excel=str(excel_path), category_key=parsed_dict.get("category_key"), + tenant_id=getattr(user, 'tenant_id', None), ) # 5. Seed material aliases @@ -260,6 +261,7 @@ async def finalize_excel( created_by=user.id, source_excel=str(excel_path), notes=body.notes, + tenant_id=getattr(user, 'tenant_id', None), ) db.add(order) await db.flush() @@ -292,6 +294,7 @@ async def finalize_excel( for c in row.get("components", []) ], cad_file_id=inherited_cad, + tenant_id=getattr(user, 'tenant_id', None), ) db.add(item) @@ -316,6 +319,7 @@ async def finalize_excel( product_id=uuid.UUID(product_id), output_type_id=None, gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"), + tenant_id=getattr(user, 'tenant_id', None), ) db.add(line) else: @@ -325,6 +329,7 @@ async def finalize_excel( product_id=uuid.UUID(product_id), output_type_id=type_id, gewuenschte_bildnummer=row.get("gewuenschte_bildnummer"), + tenant_id=getattr(user, 'tenant_id', None), ) db.add(line) @@ -410,6 +415,7 @@ async def upload_step( file_hash=file_hash, file_size=len(content), processing_status=ProcessingStatus.pending, + tenant_id=getattr(user, 'tenant_id', None), ) db.add(cad_file) await db.commit() diff --git a/backend/app/domains/products/service.py b/backend/app/domains/products/service.py index ebebc2d..8f341f8 100644 --- a/backend/app/domains/products/service.py +++ b/backend/app/domains/products/service.py @@ -69,7 +69,7 @@ async def lookup_product( async def lookup_or_create_product( - db: AsyncSession, pim_id: str | None, fields: dict + db: AsyncSession, pim_id: str | None, fields: dict, tenant_id=None ) -> tuple[Product, bool]: """Look up by produkt_baureihe (primary), then pim_id (fallback). Create if not found. @@ -120,6 +120,7 @@ async def lookup_or_create_product( components=fields.get("components", []), cad_part_materials=fields.get("cad_part_materials", []), source_excel=fields.get("source_excel"), + tenant_id=tenant_id, ) db.add(product) await db.flush() diff --git a/backend/app/domains/rendering/dispatch_service.py b/backend/app/domains/rendering/dispatch_service.py index 9928232..2b7edda 100644 --- a/backend/app/domains/rendering/dispatch_service.py +++ b/backend/app/domains/rendering/dispatch_service.py @@ -110,9 +110,7 @@ def dispatch_render_with_workflow(order_line_id: str) -> dict: def _legacy_dispatch(order_line_id: str) -> dict: - """Delegate to the original render_dispatcher logic (kept for backward compat).""" - # Import the original full implementation (not the shim) to avoid circular imports. - # The original logic lives inline in the orders router / step_tasks path; - # here we re-use the existing flamenco/celery routing code. - from app.services.render_dispatcher import dispatch_render # noqa: F401 — shim re-export - return dispatch_render(order_line_id) + """Queue render_order_line_task (the working Celery render implementation).""" + from app.tasks.step_tasks import render_order_line_task + render_order_line_task.delay(order_line_id) + return {"backend": "celery", "queued": True} diff --git a/backend/app/domains/rendering/service.py b/backend/app/domains/rendering/service.py index 023f86a..c4e2dbf 100644 --- a/backend/app/domains/rendering/service.py +++ b/backend/app/domains/rendering/service.py @@ -1,6 +1,7 @@ """Rendering services — template resolution, dispatch, and Blender utilities.""" -# Re-export from original service files for backward compatibility. +# Re-export from canonical service files for backward compatibility. +# template_service contains the actual sync implementations (Celery-safe). from app.services.template_service import resolve_template, get_material_library_path from app.services.render_dispatcher import dispatch_render from app.services.render_blender import find_blender, is_blender_available diff --git a/backend/app/services/excel_import.py b/backend/app/services/excel_import.py index ce2a5ad..4905e7d 100644 --- a/backend/app/services/excel_import.py +++ b/backend/app/services/excel_import.py @@ -36,6 +36,7 @@ async def import_excel_to_products( parsed_rows: list[dict], source_excel: str, category_key: str | None = None, + tenant_id=None, ) -> ImportResult: """For each row, look up or create a Product. @@ -78,7 +79,7 @@ async def import_excel_to_products( "source_excel": source_excel, } - product, was_created = await lookup_or_create_product(db, pim_id, fields) + product, was_created = await lookup_or_create_product(db, pim_id, fields, tenant_id=tenant_id) row["product_id"] = str(product.id) row["product_created"] = was_created # Carry forward any STEP file already linked to this product diff --git a/backend/app/services/render_dispatcher.py b/backend/app/services/render_dispatcher.py index 952afed..f344b0a 100644 --- a/backend/app/services/render_dispatcher.py +++ b/backend/app/services/render_dispatcher.py @@ -1,3 +1,9 @@ -# Compat shim — use app.domains.rendering.service instead -from app.domains.rendering.service import dispatch_render +# Compat shim — routes to render_order_line_task (the working implementation) +def dispatch_render(order_line_id: str) -> dict: + """Queue render_order_line_task for the given order line.""" + from app.tasks.step_tasks import render_order_line_task + render_order_line_task.delay(order_line_id) + return {"backend": "celery", "queued": True} + + __all__ = ["dispatch_render"] diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py index 45085d1..2646496 100644 --- a/backend/app/services/template_service.py +++ b/backend/app/services/template_service.py @@ -1,3 +1,102 @@ -# Compat shim — use app.domains.rendering.service instead -from app.domains.rendering.service import resolve_template, get_material_library_path -__all__ = ["resolve_template", "get_material_library_path"] +"""Render template resolution service. + +Used from Celery tasks (sync context) to find the best matching .blend template +for a given category + output type combination. + +Cascade priority (first active match wins): +1. Exact: category_key + output_type_id +2. Category only: category_key + output_type_id IS NULL +3. OT only: category_key IS NULL + output_type_id +4. Global: both NULL +5. No template → caller falls back to factory-settings behavior +""" +import logging + +from sqlalchemy import create_engine, select, and_ +from sqlalchemy.orm import Session + +from app.models.render_template import RenderTemplate +from app.models.system_setting import SystemSetting + +logger = logging.getLogger(__name__) + +_engine = None + + +def _get_engine(): + global _engine + if _engine is None: + from app.config import settings as app_settings + _engine = create_engine(app_settings.database_url_sync) + return _engine + + +def resolve_template( + category_key: str | None = None, + output_type_id: str | None = None, +) -> RenderTemplate | None: + """Find the best matching active render template. + + Uses sync SQLAlchemy — safe for Celery tasks. + """ + engine = _get_engine() + with Session(engine) as session: + active = RenderTemplate.is_active == True # noqa: E712 + + # 1. Exact match + if category_key and output_type_id: + row = session.execute( + select(RenderTemplate).where(and_( + active, + RenderTemplate.category_key == category_key, + RenderTemplate.output_type_id == output_type_id, + )) + ).scalar_one_or_none() + if row: + return row + + # 2. Category only + if category_key: + row = session.execute( + select(RenderTemplate).where(and_( + active, + RenderTemplate.category_key == category_key, + RenderTemplate.output_type_id.is_(None), + )) + ).scalar_one_or_none() + if row: + return row + + # 3. OT only + if output_type_id: + row = session.execute( + select(RenderTemplate).where(and_( + active, + RenderTemplate.category_key.is_(None), + RenderTemplate.output_type_id == output_type_id, + )) + ).scalar_one_or_none() + if row: + return row + + # 4. Global fallback (both NULL) + row = session.execute( + select(RenderTemplate).where(and_( + active, + RenderTemplate.category_key.is_(None), + RenderTemplate.output_type_id.is_(None), + )) + ).scalar_one_or_none() + return row + + +def get_material_library_path() -> str | None: + """Read material_library_path from system_settings. Returns None if empty.""" + engine = _get_engine() + with Session(engine) as session: + row = session.execute( + select(SystemSetting).where(SystemSetting.key == "material_library_path") + ).scalar_one_or_none() + if row and row.value and row.value.strip(): + return row.value.strip() + return None diff --git a/backend/app/tasks/step_tasks.py b/backend/app/tasks/step_tasks.py index c9efa30..448ceab 100644 --- a/backend/app/tasks/step_tasks.py +++ b/backend/app/tasks/step_tasks.py @@ -239,42 +239,12 @@ def regenerate_thumbnail(self, cad_file_id: str, part_colors: dict): @celery_app.task(name="app.tasks.step_tasks.dispatch_order_line_render", queue="step_processing") def dispatch_order_line_render(order_line_id: str): - """Thin wrapper that calls render_dispatcher.dispatch_render().""" + """Route an order-line render to render_order_line_task.""" logger.info(f"Dispatching render for order line: {order_line_id}") - try: - from app.services.render_dispatcher import dispatch_render - result = dispatch_render(order_line_id) - logger.info(f"Dispatch result for {order_line_id}: {result}") - return result - except Exception as exc: - logger.error(f"dispatch_order_line_render failed for {order_line_id}: {exc}") - # Mark line as failed so it doesn't stay stuck in "processing" - try: - from sqlalchemy import create_engine, update as sql_update - from sqlalchemy.orm import Session - from app.config import settings as app_settings - from app.models.order_line import OrderLine - from datetime import datetime - sync_url = app_settings.database_url.replace("+asyncpg", "") - eng = create_engine(sync_url) - with Session(eng) as s: - s.execute( - sql_update(OrderLine) - .where(OrderLine.id == order_line_id) - .values( - render_status="failed", - render_completed_at=datetime.utcnow(), - render_log={"error": f"Dispatch failed: {str(exc)[:500]}"}, - ) - ) - s.commit() - eng.dispose() - except Exception: - logger.exception(f"Failed to mark {order_line_id} as failed after dispatch error") - raise + render_order_line_task.delay(order_line_id) -@celery_app.task(bind=True, name="app.tasks.step_tasks.render_order_line_task", queue="step_processing", max_retries=3) +@celery_app.task(bind=True, name="app.tasks.step_tasks.render_order_line_task", queue="thumbnail_rendering", max_retries=3) def render_order_line_task(self, order_line_id: str): """Render a specific output type for an order line. diff --git a/docker-compose.yml b/docker-compose.yml index a42c1e8..117d7eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,37 +112,6 @@ services: redis: condition: service_healthy - worker-thumbnail: - build: - context: ./backend - dockerfile: Dockerfile - command: celery -A app.tasks.celery_app worker --loglevel=info -Q thumbnail_rendering --concurrency=1 - environment: - - POSTGRES_DB=${POSTGRES_DB:-schaeffler} - - POSTGRES_USER=${POSTGRES_USER:-schaeffler} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-schaeffler} - - POSTGRES_HOST=postgres - - POSTGRES_PORT=5432 - - REDIS_URL=${REDIS_URL:-redis://redis:6379/0} - - JWT_SECRET_KEY=${JWT_SECRET_KEY:-changeme-in-production} - - AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY:-} - - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT:-} - - AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT:-gpt-4o} - - AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION:-2024-02-01} - - UPLOAD_DIR=/app/uploads - - MINIO_URL=${MINIO_URL:-http://minio:9000} - - MINIO_USER=${MINIO_USER:-minioadmin} - - MINIO_PASSWORD=${MINIO_PASSWORD:-minioadmin} - - MINIO_BUCKET=${MINIO_BUCKET:-uploads} - volumes: - - ./backend:/app - - uploads:/app/uploads - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - render-worker: build: context: . diff --git a/frontend/src/api/tenants.ts b/frontend/src/api/tenants.ts index c82ce2e..16845c2 100644 --- a/frontend/src/api/tenants.ts +++ b/frontend/src/api/tenants.ts @@ -22,7 +22,7 @@ export interface TenantUpdate { } export async function getTenants(): Promise { - const res = await api.get('/tenants') + const res = await api.get('/tenants/') return res.data } diff --git a/frontend/src/components/admin/OutputTypeTable.tsx b/frontend/src/components/admin/OutputTypeTable.tsx index 6396850..59a48fc 100644 --- a/frontend/src/components/admin/OutputTypeTable.tsx +++ b/frontend/src/components/admin/OutputTypeTable.tsx @@ -9,9 +9,8 @@ import type { OutputType } from '../../api/outputTypes' import { listPricingTiers } from '../../api/pricing' import type { PricingTier } from '../../api/pricing' -const RENDERERS = ['threejs', 'blender', 'pillow'] +const RENDERERS = ['blender', 'pillow'] const FORMATS = ['png', 'jpg', 'gltf', 'stl', 'mp4', 'webm'] -const BACKENDS = ['auto', 'celery', 'flamenco'] const ALL_CATEGORIES = [ { key: 'TRB', label: 'TRB' }, { key: 'Kugellager', label: 'Kugellager' }, @@ -173,10 +172,9 @@ export default function OutputTypeTable() { Name - Renderer + Renderer Format - Backend - Anim + Anim Turntable BG Device @@ -194,7 +192,7 @@ export default function OutputTypeTable() { {isLoading && ( - Loading… + Loading… )} {types?.map((ot) => ( @@ -226,15 +224,6 @@ export default function OutputTypeTable() { {FORMATS.map((f) => )} - - - {ot.name} {ot.renderer} {ot.output_format} - - - {ot.render_backend} - - {ot.is_animation && ( video @@ -557,13 +537,13 @@ export default function OutputTypeTable() { {ot.transparent_bg && ( alpha )} - {ot.render_settings?.bg_color && ( + {!!ot.render_settings?.bg_color && (
- {ot.render_settings.bg_color} + {ot.render_settings.bg_color as string}
)} @@ -604,7 +584,7 @@ export default function OutputTypeTable() { {showBlenderSettings(ot.renderer) ? ( ot.render_settings?.samples ? ( - {ot.render_settings.samples} + {ot.render_settings.samples as number} ) : ( default ) @@ -621,7 +601,7 @@ export default function OutputTypeTable() { ) : null} {ot.render_settings?.noise_threshold ? ( - t={ot.render_settings.noise_threshold} + t={ot.render_settings.noise_threshold as string} ) : null} {ot.render_settings?.denoising_prefilter ? ( {ot.render_settings.denoising_prefilter as string} @@ -728,15 +708,6 @@ export default function OutputTypeTable() { {FORMATS.map((f) => )} - - - (null) - const [showFlamencoAdvanced, setShowFlamencoAdvanced] = useState(false) - const [flamencoUrlDraft, setFlamencoUrlDraft] = useState('') - const [workerCountDraft, setWorkerCountDraft] = useState(1) const [priorityNewEntry, setPriorityNewEntry] = useState('') const { data: users } = useQuery({ @@ -76,8 +73,6 @@ export default function AdminPage() { render_stall_timeout_minutes: number product_thumbnail_priority: string // JSON array render_backend: string - flamenco_manager_url: string - flamenco_worker_count: number smtp_enabled: boolean smtp_host: string smtp_port: number @@ -159,44 +154,6 @@ export default function AdminPage() { const [smtpDraft, setSmtpDraft] = useState>({}) const smtp = { ...settings, ...smtpDraft } as Settings - type FlamencoStatus = { - manager: { available: boolean; version: string | null; name: string | null; error?: string } - workers: any[] - manager_url: string - } - - const { data: flamencoStatus, refetch: refetchFlamenco } = useQuery({ - queryKey: ['flamenco-status'], - queryFn: async () => { - const res = await api.get('/admin/settings/flamenco-status') - return res.data as FlamencoStatus - }, - refetchInterval: 30000, - enabled: isAdmin, - }) - - const { data: actualWorkers, refetch: refetchActualWorkers } = useQuery({ - queryKey: ['flamenco-worker-actual'], - queryFn: () => api.get('/admin/settings/flamenco-worker-actual').then(r => r.data as { running: number; available: boolean }), - refetchInterval: 10000, - enabled: isAdmin, - }) - - const setWorkerCountMut = useMutation({ - mutationFn: (count: number) => api.post('/admin/settings/flamenco-worker-count', { count }), - onSuccess: (res) => { - const d = res.data - if (d.current >= 0) { - toast.success(`Workers scaled: ${d.previous} → ${d.current}`) - } else { - toast.warning(d.message || 'Setting saved — manual scaling may be needed') - } - qc.invalidateQueries({ queryKey: ['admin-settings'] }) - refetchActualWorkers() - }, - onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), - }) - return (

Admin

@@ -261,182 +218,6 @@ export default function AdminPage() {
- {/* ------------------------------------------------------------------ */} - {/* Render Farm (admin only) */} - {/* ------------------------------------------------------------------ */} - {isAdmin &&
-
-
- -
-

Render Farm

-

- Route render jobs to Celery (stills) or Flamenco (animations). -

-
-
- -
- -
- {/* Global backend selector */} -
- - {(['celery', 'flamenco', 'auto'] as const).map((b) => ( - - ))} - {settings?.render_backend === 'auto' && ( -

Stills via Celery, animations via Flamenco

- )} -
- - {/* Flamenco status panel */} -
-
-

Flamenco Status

- {flamencoStatus?.manager?.available && ( - - Open Flamenco Web UI - - )} -
- -
- {/* Manager health */} -
- {flamencoStatus?.manager?.available - ? - : } -
-

Manager

-

- {flamencoStatus?.manager?.available - ? `v${flamencoStatus.manager.version || '?'}` - : flamencoStatus?.manager?.error || 'Offline'} -

-
-
- - {/* Workers */} -
-

- Workers: {flamencoStatus?.workers?.length ?? 0} -

- {flamencoStatus?.workers && flamencoStatus.workers.length > 0 && ( -
- {flamencoStatus.workers.slice(0, 5).map((w: any, i: number) => ( -
- - {w.name || `worker-${i + 1}`} - {w.status || '—'} -
- ))} -
- )} -
-
- - {/* Worker count control */} -
- Worker count: - setWorkerCountDraft(Number(e.target.value))} - title="Number of Flamenco worker containers to run (1–16). Each worker handles one render job at a time." - className="w-20 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent" - /> - - {actualWorkers?.available ? ( - - {actualWorkers.running} running - - ) : ( - Docker socket unavailable - )} -
- - {/* Advanced: Manager URL */} -
- - {showFlamencoAdvanced && ( -
- - setFlamencoUrlDraft(e.target.value)} - className="flex-1 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent" - placeholder="http://flamenco-manager:8080" - /> - -
- )} -
-
-
-
} - {/* ------------------------------------------------------------------ */} {/* Users (admin only) */} {/* ------------------------------------------------------------------ */} @@ -538,7 +319,7 @@ export default function AdminPage() { {/* Renderer picker */}
- {(['pillow', 'blender', 'threejs'] as const).map((r) => ( + {(['blender', 'pillow'] as const).map((r) => ( ))}
@@ -742,33 +521,6 @@ export default function AdminPage() { )} - {/* Three.js options — shown only when threejs is the active renderer */} - {settings?.thumbnail_renderer === 'threejs' && ( -
-

Three.js (WebGL) Options

-
- Render size - {([512, 1024, 2048] as const).map((size) => ( - - ))} -
-

- Higher resolution = larger PNG thumbnails. 1024px recommended for most screens. -

-
- )} - {/* Output format — always visible, applies to all renderers */}