diff --git a/LEARNINGS.md b/LEARNINGS.md index 15bd486..5942a9f 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -329,6 +329,20 @@ SQLAlchemy `Enum(create_type=False)` funktioniert nicht zuverlässig mit asyncpg --- +### 2026-03-07 | SQLAlchemy Async | db.refresh() lädt keine Relationships +**Problem:** `create_invoice` rief `await db.refresh(invoice)` — lädt nur skalare Spalten, nicht `invoice.lines` (Relationship). FastAPI serialisiert danach `lines` → SQLAlchemy versucht lazy-load außerhalb eines Greenlets → `MissingGreenlet`-Exception, HTTP 500. +**Lösung:** Statt `db.refresh()` die bestehende `get_invoice(db, invoice.id)` Funktion aufrufen, die `selectinload(Invoice.lines)` verwendet und alle Relationships korrekt vorlädt. +**Regel:** Nach `db.commit()` in Diensten die Relationships brauchen, immer eine separate select-Query mit `selectinload` machen anstatt `db.refresh()`. + +--- + +### 2026-03-07 | Frontend Auth | Bearer-Token bei direktem Link-Download fehlt +**Problem:** `` öffnet den Link direkt im Browser ohne `Authorization`-Header → Backend gibt 401/403 zurück. +**Lösung:** API-Call via `api.get(..., { responseType: 'blob' })` (axios-Client mit automatischem Auth-Header), dann `URL.createObjectURL()` + programmatischer `.click()`. So geht der Auth-Token mit. +**Gilt für:** Alle geschützten Download-Endpoints (PDFs, ZIPs, etc.) die via direkten Link nicht erreichbar sind. + +--- + ### 2026-03-06 | TypeScript | Test-Dateien aus Haupt-tsconfig ausschließen **Problem:** `vitest`- und `msw`-Imports in `src/__tests__/` erzeugen TypeScript-Fehler in `tsc --noEmit` weil diese Packages ihre Typen nur im Test-Kontext (über vitest globals) bereitstellen. `tsc` kennt die Types nicht, obwohl die Packages installiert sind. **Lösung:** In `tsconfig.json` ein `"exclude": ["src/__tests__"]` hinzufügen. Vitest führt seine eigene Typ-Prüfung durch; der Haupt-Build braucht nur Produktionscode zu prüfen. diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py index f1ae9c6..1424d09 100644 --- a/backend/app/api/routers/admin.py +++ b/backend/app/api/routers/admin.py @@ -470,3 +470,76 @@ async def renderer_status( } +@router.post("/import-media-assets") +async def import_existing_media_assets( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin), +): + """Import existing cad thumbnails and order line renders as MediaAsset records.""" + from app.domains.media.models import MediaAsset, MediaAssetType + from sqlalchemy import text + + created = 0 + skipped = 0 + + # 1. CadFiles with thumbnail_path + cad_result = await db.execute( + text("SELECT id, thumbnail_path FROM cad_files WHERE thumbnail_path IS NOT NULL AND status = 'completed'") + ) + for row in cad_result.fetchall(): + cad_id, thumb_path = row + # De-dup check + existing = await db.execute( + select(MediaAsset.id).where(MediaAsset.storage_key == thumb_path).limit(1) + ) + if existing.scalar_one_or_none(): + skipped += 1 + continue + ext = str(thumb_path).lower() + mime = "image/jpeg" if ext.endswith(".jpg") or ext.endswith(".jpeg") else "image/png" + asset = MediaAsset( + cad_file_id=uuid.UUID(str(cad_id)), + asset_type=MediaAssetType.thumbnail, + storage_key=str(thumb_path), + mime_type=mime, + ) + db.add(asset) + created += 1 + + # 2. OrderLines with result_path + ol_result = await db.execute( + text(""" + SELECT ol.id, ol.result_path, ol.product_id, COALESCE(ot.is_animation, false) as is_animation + FROM order_lines ol + LEFT JOIN output_types ot ON ot.id = ol.output_type_id + WHERE ol.result_path IS NOT NULL AND ol.render_status = 'completed' + """) + ) + for row in ol_result.fetchall(): + ol_id, result_path, product_id, is_animation = row + existing = await db.execute( + select(MediaAsset.id).where(MediaAsset.storage_key == result_path).limit(1) + ) + if existing.scalar_one_or_none(): + skipped += 1 + continue + ext = str(result_path).lower() + if ext.endswith(".mp4") or ext.endswith(".webm"): + mime = "video/mp4" + asset_type = MediaAssetType.turntable + else: + mime = "image/png" if ext.endswith(".png") else "image/jpeg" + asset_type = MediaAssetType.turntable if is_animation else MediaAssetType.still + asset = MediaAsset( + order_line_id=uuid.UUID(str(ol_id)), + product_id=uuid.UUID(str(product_id)) if product_id else None, + asset_type=asset_type, + storage_key=str(result_path), + mime_type=mime, + ) + db.add(asset) + created += 1 + + await db.commit() + return {"created": created, "skipped": skipped} + diff --git a/backend/app/api/routers/task_logs.py b/backend/app/api/routers/task_logs.py new file mode 100644 index 0000000..3e274c4 --- /dev/null +++ b/backend/app/api/routers/task_logs.py @@ -0,0 +1,78 @@ +"""SSE endpoint for live task log streaming.""" +from __future__ import annotations +import asyncio +import json +import logging +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse +from app.utils.auth import get_current_user +from app.config import settings + +router = APIRouter(prefix="/tasks", tags=["task-logs"]) +logger = logging.getLogger(__name__) + + +@router.get("/{task_id}/logs") +async def stream_task_logs( + task_id: str, + current_user=Depends(get_current_user), +): + """SSE stream of task log lines. Use fetch() with Authorization header on the frontend.""" + import redis.asyncio as aioredis + + async def event_stream(): + r = aioredis.from_url(settings.redis_url) + try: + # Send heartbeat first + yield "data: {\"type\":\"connected\"}\n\n" + + # Send existing log lines + existing = await r.lrange(f"task_logs:{task_id}", 0, -1) + for line in existing: + data = line.decode() if isinstance(line, bytes) else line + yield f"data: {data}\n\n" + + # Subscribe and stream new entries + pubsub = r.pubsub() + await pubsub.subscribe(f"task_logs_ch:{task_id}") + + timeout_seconds = 600 # 10 minutes max + deadline = asyncio.get_event_loop().time() + timeout_seconds + + while asyncio.get_event_loop().time() < deadline: + try: + msg = await asyncio.wait_for( + pubsub.get_message(ignore_subscribe_messages=True), + timeout=2.0 + ) + if msg and msg["type"] == "message": + data = msg["data"].decode() if isinstance(msg["data"], bytes) else msg["data"] + yield f"data: {data}\n\n" + # Check if task completed + try: + parsed = json.loads(data) + if parsed.get("level") == "done": + break + except Exception: + pass + else: + # Heartbeat every 2s + yield ": heartbeat\n\n" + except asyncio.TimeoutError: + yield ": heartbeat\n\n" + except Exception as exc: + logger.error("SSE stream error for task %s: %s", task_id, exc) + finally: + try: + await r.aclose() + except Exception: + pass + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/app/core/storage.py b/backend/app/core/storage.py index 432eb5b..38f905b 100644 --- a/backend/app/core/storage.py +++ b/backend/app/core/storage.py @@ -56,8 +56,9 @@ class MinIOStorage: except Exception as exc: logger.warning("Could not create MinIO bucket %s: %s", self._bucket, exc) - def upload(self, local_path: Path, object_key: str) -> str: + def upload(self, local_path: Path | str, object_key: str) -> str: """Upload a local file to MinIO. Returns the object_key.""" + local_path = Path(local_path) self._client.upload_file(str(local_path), self._bucket, object_key) logger.debug("Uploaded %s → minio://%s/%s", local_path.name, self._bucket, object_key) return object_key diff --git a/backend/app/core/task_logs.py b/backend/app/core/task_logs.py new file mode 100644 index 0000000..d9d543c --- /dev/null +++ b/backend/app/core/task_logs.py @@ -0,0 +1,24 @@ +"""Redis-backed task log store for SSE streaming.""" +import json +import time +import logging +from app.config import settings + +logger = logging.getLogger(__name__) +TASK_LOG_TTL = 3600 # 1 hour + + +def log_task_event(task_id: str, message: str, level: str = "info") -> None: + """Append a log line to Redis list and publish to channel. Safe to call from Celery tasks.""" + try: + import redis + r = redis.from_url(settings.redis_url) + entry = json.dumps({"ts": time.time(), "level": level, "msg": message, "task_id": task_id}) + pipe = r.pipeline() + pipe.rpush(f"task_logs:{task_id}", entry) + pipe.expire(f"task_logs:{task_id}", TASK_LOG_TTL) + pipe.publish(f"task_logs_ch:{task_id}", entry) + pipe.execute() + r.close() + except Exception as exc: + logger.debug("log_task_event failed: %s", exc) diff --git a/backend/app/domains/billing/router.py b/backend/app/domains/billing/router.py index 90ea4d7..c28463d 100644 --- a/backend/app/domains/billing/router.py +++ b/backend/app/domains/billing/router.py @@ -2,7 +2,7 @@ from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.responses import RedirectResponse +from fastapi.responses import Response from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -83,8 +83,12 @@ async def download_invoice_pdf( if not key: raise HTTPException(status_code=503, detail="PDF generation unavailable (WeasyPrint not installed)") from app.core.storage import get_storage - url = get_storage().get_url(key) - return RedirectResponse(url=url) + pdf_bytes = get_storage().download_bytes(key) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=invoice-{invoice_id}.pdf"}, + ) @invoice_router.delete("/invoices/{invoice_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/domains/billing/schemas.py b/backend/app/domains/billing/schemas.py index 8b24994..142bbda 100644 --- a/backend/app/domains/billing/schemas.py +++ b/backend/app/domains/billing/schemas.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid from datetime import date, datetime from decimal import Decimal -from pydantic import BaseModel +from pydantic import BaseModel, computed_field class InvoiceLineCreate(BaseModel): @@ -54,4 +54,11 @@ class InvoiceOut(BaseModel): created_at: datetime lines: list[InvoiceLineOut] = [] + @computed_field # type: ignore[misc] + @property + def pdf_url(self) -> str | None: + if self.pdf_key: + return f"/api/billing/invoices/{self.id}/pdf" + return None + model_config = {"from_attributes": True} diff --git a/backend/app/domains/billing/service.py b/backend/app/domains/billing/service.py index f5e934b..3d4ca30 100644 --- a/backend/app/domains/billing/service.py +++ b/backend/app/domains/billing/service.py @@ -267,8 +267,7 @@ async def create_invoice( invoice.total_net = total_net invoice.total_vat = (total_net * vat_rate).quantize(Decimal("0.01")) await db.commit() - await db.refresh(invoice) - return invoice + return await get_invoice(db, invoice.id) async def get_invoices( diff --git a/backend/app/domains/media/router.py b/backend/app/domains/media/router.py index 90cceb7..106cba9 100644 --- a/backend/app/domains/media/router.py +++ b/backend/app/domains/media/router.py @@ -12,15 +12,17 @@ from app.domains.media.models import MediaAssetType from app.domains.media.schemas import MediaAssetOut from app.domains.media import service -router = APIRouter(prefix="/api/media", tags=["media"]) +router = APIRouter(prefix="/api/media", tags=["media"], redirect_slashes=False) -@router.get("/", response_model=list[MediaAssetOut]) +@router.get("", response_model=list[MediaAssetOut]) +@router.get("/", response_model=list[MediaAssetOut], include_in_schema=False) async def list_assets( product_id: uuid.UUID | None = None, order_line_id: uuid.UUID | None = None, cad_file_id: uuid.UUID | None = None, asset_type: MediaAssetType | None = None, + asset_types: list[MediaAssetType] = Query(default=[]), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=500), db: AsyncSession = Depends(get_db), @@ -31,11 +33,13 @@ async def list_assets( order_line_id=order_line_id, cad_file_id=cad_file_id, asset_type=asset_type, + asset_types=asset_types if asset_types else None, skip=skip, limit=limit, ) for a in assets: a.download_url = service.get_download_url(a) + a.thumbnail_url = service.get_thumbnail_url(a) return assets @@ -45,19 +49,45 @@ async def get_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)): if not asset: raise HTTPException(404, "Asset not found") asset.download_url = service.get_download_url(asset) + asset.thumbnail_url = service.get_thumbnail_url(asset) return asset -@router.get("/{asset_id}/download") +@router.api_route("/{asset_id}/download", methods=["GET", "HEAD"]) async def download_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)): - from fastapi.responses import RedirectResponse + """Proxy file content directly — avoids internal MinIO hostname issues.""" + from fastapi.responses import FileResponse, Response + from pathlib import Path asset = await service.get_media_asset(db, asset_id) if not asset: raise HTTPException(404, "Asset not found") - url = service.get_download_url(asset) - if url: - return RedirectResponse(url) - raise HTTPException(404, "File not available") + + key = asset.storage_key + mime = asset.mime_type or "application/octet-stream" + + # Local file path (absolute or relative to UPLOAD_DIR) + candidate = Path(key) + if not candidate.is_absolute(): + from app.config import settings + candidate = Path(settings.UPLOAD_DIR) / key + if candidate.exists(): + ext = candidate.suffix.lstrip(".") + fname = f"{asset.asset_type.value}_{asset_id}.{ext or 'bin'}" + return FileResponse(str(candidate), media_type=mime, filename=fname) + + # Fall back to MinIO + try: + from app.core.storage import get_storage + data = get_storage().download_bytes(key) + ext = key.rsplit(".", 1)[-1] if "." in key else "bin" + fname = f"{asset.asset_type.value}_{asset_id}.{ext}" + return Response( + content=data, + media_type=mime, + headers={"Content-Disposition": f"attachment; filename={fname}"}, + ) + except Exception: + raise HTTPException(404, "File not available") @router.post("/zip") diff --git a/backend/app/domains/media/schemas.py b/backend/app/domains/media/schemas.py index ba4e1c3..f6e08c5 100644 --- a/backend/app/domains/media/schemas.py +++ b/backend/app/domains/media/schemas.py @@ -22,5 +22,6 @@ class MediaAssetOut(BaseModel): is_archived: bool created_at: datetime download_url: str | None = None + thumbnail_url: str | None = None model_config = {"from_attributes": True} diff --git a/backend/app/domains/media/service.py b/backend/app/domains/media/service.py index ab053c3..3bd37da 100644 --- a/backend/app/domains/media/service.py +++ b/backend/app/domains/media/service.py @@ -11,6 +11,7 @@ async def list_media_assets( order_line_id: uuid.UUID | None = None, cad_file_id: uuid.UUID | None = None, asset_type: MediaAssetType | None = None, + asset_types: list[MediaAssetType] | None = None, is_archived: bool | None = False, skip: int = 0, limit: int = 50, @@ -22,7 +23,9 @@ async def list_media_assets( q = q.where(MediaAsset.order_line_id == order_line_id) if cad_file_id: q = q.where(MediaAsset.cad_file_id == cad_file_id) - if asset_type: + if asset_types: + q = q.where(MediaAsset.asset_type.in_(asset_types)) + elif asset_type is not None: q = q.where(MediaAsset.asset_type == asset_type) if is_archived is not None: q = q.where(MediaAsset.is_archived == is_archived) @@ -62,10 +65,12 @@ async def delete_media_asset(db: AsyncSession, asset_id: uuid.UUID) -> bool: def get_download_url(asset: MediaAsset) -> str | None: - """Get presigned URL from MinIO or local path.""" - try: - from app.core.storage import get_storage - storage = get_storage() - return storage.get_url(asset.storage_key) - except Exception: - return f"/uploads/{asset.storage_key}" + """Return a backend proxy URL so the browser can always download the file.""" + return f"/api/media/{asset.id}/download" + + +def get_thumbnail_url(asset: MediaAsset) -> str | None: + """Return CAD thumbnail URL if asset has a cad_file_id.""" + if asset.cad_file_id: + return f"/api/cad/{asset.cad_file_id}/thumbnail" + return None diff --git a/backend/app/domains/rendering/tasks.py b/backend/app/domains/rendering/tasks.py index e4c60f3..53f2e81 100644 --- a/backend/app/domains/rendering/tasks.py +++ b/backend/app/domains/rendering/tasks.py @@ -10,6 +10,7 @@ import logging from pathlib import Path from app.tasks.celery_app import celery_app +from app.core.task_logs import log_task_event logger = logging.getLogger(__name__) @@ -55,6 +56,7 @@ def render_still_task( Returns render metadata dict on success. Retries up to 2 times on failure (30s countdown). """ + log_task_event(self.request.id, f"Starting render_still_task: {Path(step_path).name}", "info") try: from app.services.render_blender import render_still result = render_still( @@ -86,14 +88,34 @@ def render_still_task( denoising_use_gpu=denoising_use_gpu, mesh_attributes=mesh_attributes or {}, ) + log_task_event(self.request.id, f"Completed successfully in {result.get('total_duration_s', 0):.1f}s", "done") logger.info( "render_still_task completed: %s → %s in %.1fs", Path(step_path).name, Path(output_path).name, result.get("total_duration_s", 0), ) + try: + from app.core.websocket import publish_event_sync + publish_event_sync(None, { + "type": "render.still.completed", + "step_path": Path(step_path).name, + "output": Path(output_path).name, + }) + except Exception: + pass return result except Exception as exc: + log_task_event(self.request.id, f"Failed: {exc}", "error") logger.error("render_still_task failed for %s: %s", step_path, exc) + try: + from app.core.websocket import publish_event_sync + publish_event_sync(None, { + "type": "render.still.failed", + "step_path": Path(step_path).name, + "error": str(exc), + }) + except Exception: + pass raise self.retry(exc=exc, countdown=30) @@ -136,6 +158,7 @@ def render_turntable_task( Returns render metadata dict on success. """ + log_task_event(self.request.id, f"Starting render_turntable_task: {Path(step_path).name}", "info") import json import os import shutil @@ -211,7 +234,17 @@ def render_turntable_task( f"Blender turntable exited {result.returncode}:\n{result.stdout[-2000:]}" ) except Exception as exc: + log_task_event(self.request.id, f"Failed: {exc}", "error") logger.error("render_turntable_task failed: %s", exc) + try: + from app.core.websocket import publish_event_sync + publish_event_sync(None, { + "type": "render.turntable.failed", + "step_path": Path(step_path).name, + "error": str(exc), + }) + except Exception: + pass raise self.retry(exc=exc, countdown=60) # FFmpeg composite: frames → MP4 with optional background @@ -224,6 +257,16 @@ def render_turntable_task( except subprocess.CalledProcessError as exc: raise RuntimeError(f"FFmpeg composite failed: {exc.stderr[-500:]}") + log_task_event(self.request.id, "Completed successfully", "done") + try: + from app.core.websocket import publish_event_sync + publish_event_sync(None, { + "type": "render.turntable.completed", + "step_path": Path(step_path).name, + "output": Path(output_mp4).name, + }) + except Exception: + pass return { "output_mp4": str(output_mp4), "frame_count": frame_count, @@ -313,8 +356,10 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict: Wraps render_still_task logic but accepts order_line_id instead of step_path. On success, creates a MediaAsset record via publish_asset. """ + log_task_event(self.request.id, f"Starting render_order_line_still_task: order_line={order_line_id}", "info") step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id) if not step_path_str: + log_task_event(self.request.id, f"Failed: cannot resolve STEP path for order_line {order_line_id}", "error") raise RuntimeError( f"Cannot resolve STEP path for order_line {order_line_id}: " "product missing or has no linked CAD file" @@ -338,13 +383,32 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict: str(output_path), render_config=result, ) + log_task_event(self.request.id, f"Completed successfully in {result.get('total_duration_s', 0):.1f}s", "done") logger.info( "render_order_line_still_task completed for line %s in %.1fs", order_line_id, result.get("total_duration_s", 0), ) + try: + from app.core.websocket import publish_event_sync + publish_event_sync(None, { + "type": "render.order_line.completed", + "order_line_id": order_line_id, + }) + except Exception: + pass return result except Exception as exc: + log_task_event(self.request.id, f"Failed: {exc}", "error") logger.error("render_order_line_still_task failed for %s: %s", order_line_id, exc) + try: + from app.core.websocket import publish_event_sync + publish_event_sync(None, { + "type": "render.order_line.failed", + "order_line_id": order_line_id, + "error": str(exc), + }) + except Exception: + pass raise self.retry(exc=exc, countdown=30) diff --git a/backend/app/main.py b/backend/app/main.py index 820f5b4..41d3b9f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,6 +24,7 @@ from app.domains.rendering.workflow_router import router as workflows_router from app.domains.media.router import router as media_router from app.api.routers.asset_libraries import router as asset_libraries_router from app.domains.admin.dashboard_router import router as dashboard_router +from app.api.routers.task_logs import router as task_logs_router @asynccontextmanager @@ -90,6 +91,7 @@ app.include_router(workflows_router) app.include_router(media_router) app.include_router(asset_libraries_router, prefix="/api") app.include_router(dashboard_router, prefix="/api") +app.include_router(task_logs_router, prefix="/api") @app.get("/health") diff --git a/backend/app/tasks/step_tasks.py b/backend/app/tasks/step_tasks.py index b3dc138..25abcee 100644 --- a/backend/app/tasks/step_tasks.py +++ b/backend/app/tasks/step_tasks.py @@ -1,6 +1,7 @@ """Celery tasks for STEP file processing and thumbnail generation.""" import logging from app.tasks.celery_app import celery_app +from app.core.task_logs import log_task_event logger = logging.getLogger(__name__) @@ -268,9 +269,11 @@ def generate_gltf_geometry_task(self, cad_file_id: str): step_path_str = cad_file.stored_path eng.dispose() + log_task_event(self.request.id, f"Starting generate_gltf_geometry_task: cad_file={cad_file_id}", "info") step = _Path(step_path_str) stl_path = step.parent / f"{step.stem}_low.stl" if not stl_path.exists(): + log_task_event(self.request.id, f"Failed: STL cache not found: {stl_path}", "error") logger.error("generate_gltf_geometry_task: STL not found %s", stl_path) raise RuntimeError(f"STL cache not found: {stl_path}") @@ -279,8 +282,10 @@ def generate_gltf_geometry_task(self, cad_file_id: str): import trimesh mesh = trimesh.load(str(stl_path)) mesh.export(str(output_path)) + log_task_event(self.request.id, f"Completed successfully: {output_path.name}", "done") logger.info("generate_gltf_geometry_task: exported %s", output_path.name) except Exception as exc: + log_task_event(self.request.id, f"Failed: {exc}", "error") logger.error("generate_gltf_geometry_task failed for %s: %s", cad_file_id, exc) raise self.retry(exc=exc, countdown=15) diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts index 5b86373..96b9e21 100644 --- a/frontend/src/api/billing.ts +++ b/frontend/src/api/billing.ts @@ -60,6 +60,12 @@ export async function deleteInvoice(id: string): Promise { await api.delete(`/billing/invoices/${id}`) } -export function getInvoicePdfUrl(id: string): string { - return `/api/billing/invoices/${id}/pdf` +export async function downloadInvoicePdf(id: string): Promise { + const res = await api.get(`/billing/invoices/${id}/pdf`, { responseType: 'blob' }) + const url = URL.createObjectURL(res.data) + const a = document.createElement('a') + a.href = url + a.download = `invoice-${id}.pdf` + a.click() + URL.revokeObjectURL(url) } diff --git a/frontend/src/api/media.ts b/frontend/src/api/media.ts index 6afe368..878de05 100644 --- a/frontend/src/api/media.ts +++ b/frontend/src/api/media.ts @@ -28,13 +28,14 @@ export interface MediaAsset { is_archived: boolean created_at: string download_url: string | null + thumbnail_url: string | null } export interface MediaFilter { product_id?: string order_line_id?: string cad_file_id?: string - asset_type?: MediaAssetType + asset_types?: MediaAssetType[] skip?: number limit?: number } @@ -44,10 +45,10 @@ export const getMediaAssets = (filters: MediaFilter = {}): Promise if (filters.product_id) params.set('product_id', filters.product_id) if (filters.order_line_id) params.set('order_line_id', filters.order_line_id) if (filters.cad_file_id) params.set('cad_file_id', filters.cad_file_id) - if (filters.asset_type) params.set('asset_type', filters.asset_type) + if (filters.asset_types?.length) filters.asset_types.forEach(t => params.append('asset_types', t)) if (filters.skip !== undefined) params.set('skip', String(filters.skip)) if (filters.limit !== undefined) params.set('limit', String(filters.limit)) - return api.get(`/media?${params}`).then(r => r.data) + return api.get(`/media/?${params}`).then(r => r.data) } export const getMediaAsset = (id: string): Promise => diff --git a/frontend/src/components/cad/ThreeDViewer.tsx b/frontend/src/components/cad/ThreeDViewer.tsx index 1da08ce..8b65ec7 100644 --- a/frontend/src/components/cad/ThreeDViewer.tsx +++ b/frontend/src/components/cad/ThreeDViewer.tsx @@ -218,8 +218,6 @@ export default function ThreeDViewer({ productionGltfUrl, downloadUrls, }: ThreeDViewerProps) { - const defaultUrl = `/api/cad/${cadFileId}/model` - const [mode, setMode] = useState('geometry') const [wireframe, setWireframe] = useState(false) const [envPreset, setEnvPreset] = useState('city') @@ -231,7 +229,7 @@ export default function ThreeDViewer({ const activeUrl = mode === 'production' && productionGltfUrl ? productionGltfUrl - : geometryGltfUrl || defaultUrl + : geometryGltfUrl const handleModelReady = useCallback(() => setModelReady(true), []) const handleError = useCallback((msg: string) => setLoadError(msg), []) @@ -372,16 +370,18 @@ export default function ThreeDViewer({ - - - - - + {activeUrl && ( + + + + + + )} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index a58a4de..d9d55a4 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -1,7 +1,8 @@ import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom' -import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt, Server, Upload } from 'lucide-react' +import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt, Server, Upload, Menu, X } from 'lucide-react' import { useAuthStore } from '../../store/auth' import { clsx } from 'clsx' +import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { getWorkerActivity } from '../../api/worker' import { listOrders } from '../../api/orders' @@ -20,6 +21,7 @@ const nav = [ export default function Layout() { const { user, logout } = useAuthStore() const navigate = useNavigate() + const [sidebarOpen, setSidebarOpen] = useState(false) const { data: activity } = useQuery({ queryKey: ['worker-activity'], @@ -43,8 +45,36 @@ export default function Layout() { return ( + {/* Mobile top header bar */} + + setSidebarOpen(true)} + className="text-content-secondary hover:text-content transition-colors" + aria-label="Open navigation" + > + + + Schaeffler Automat + + + + {/* Overlay backdrop (mobile only) */} + {sidebarOpen && ( + setSidebarOpen(false)} + /> + )} + {/* Sidebar */} - diff --git a/frontend/src/components/tasks/LiveRenderLog.tsx b/frontend/src/components/tasks/LiveRenderLog.tsx new file mode 100644 index 0000000..96c695a --- /dev/null +++ b/frontend/src/components/tasks/LiveRenderLog.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from 'react' +import { Loader2, Terminal } from 'lucide-react' + +interface LogEntry { + ts: number + level: 'info' | 'error' | 'done' | 'warning' + msg: string + task_id?: string +} + +interface LiveRenderLogProps { + taskId: string | null + title?: string + maxLines?: number +} + +export default function LiveRenderLog({ taskId, title = 'Task Log', maxLines = 200 }: LiveRenderLogProps) { + const [logs, setLogs] = useState([]) + const [connected, setConnected] = useState(false) + const [done, setDone] = useState(false) + const bottomRef = useRef(null) + + useEffect(() => { + if (!taskId) return + setLogs([]) + setConnected(false) + setDone(false) + + const controller = new AbortController() + const token = localStorage.getItem('token') ?? '' + + fetch(`/api/tasks/${taskId}/logs`, { + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }).then(async (res) => { + if (!res.ok || !res.body) return + setConnected(true) + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { done: streamDone, value } = await reader.read() + if (streamDone) break + buffer += decoder.decode(value, { stream: true }) + const parts = buffer.split('\n\n') + buffer = parts.pop() ?? '' + for (const part of parts) { + const line = part.trim() + if (!line.startsWith('data:')) continue + const raw = line.slice(5).trim() + try { + const entry = JSON.parse(raw) as LogEntry & { type?: string } + if (entry.type === 'connected') continue + setLogs((prev) => [...prev.slice(-maxLines + 1), entry]) + if (entry.level === 'done') setDone(true) + } catch {} + } + } + }).catch(() => {}) + + return () => controller.abort() + }, [taskId, maxLines]) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [logs]) + + if (!taskId) return null + + const levelColor = (level: string) => { + if (level === 'error') return 'text-red-400' + if (level === 'done') return 'text-green-400' + if (level === 'warning') return 'text-yellow-400' + return 'text-gray-300' + } + + return ( + + + + {title} + {!done && !connected && } + {done && Done} + {connected && !done && Live} + + + {logs.length === 0 && ( + Waiting for log output… + )} + {logs.map((entry, i) => ( + + + {new Date(entry.ts * 1000).toLocaleTimeString()} + + {entry.msg} + + ))} + + + + ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 0f95456..9417b4c 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -141,6 +141,14 @@ export default function AdminPage() { onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) + const importMediaAssetsMut = useMutation({ + mutationFn: () => api.post('/admin/import-media-assets'), + onSuccess: (res) => { + toast.success(`Imported: ${res.data.created} created, ${res.data.skipped} skipped`) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'), + }) + const generateMissingStlsMut = useMutation({ mutationFn: () => api.post('/admin/settings/generate-missing-stls'), onSuccess: (res) => { @@ -666,6 +674,18 @@ export default function AdminPage() { Re-renders thumbnails for all completed CAD files. + + importMediaAssetsMut.mutate()} + disabled={importMediaAssetsMut.isPending} + className="btn-secondary text-sm w-full justify-start" + title="Create MediaAsset records for all existing CAD thumbnails and order line renders" + > + + {importMediaAssetsMut.isPending ? 'Importing…' : 'Import Existing Media'} + + Registers existing renders & CAD thumbnails in the Media Browser. + generateMissingStlsMut.mutate()} diff --git a/frontend/src/pages/Billing.tsx b/frontend/src/pages/Billing.tsx index 25012fd..4af6317 100644 --- a/frontend/src/pages/Billing.tsx +++ b/frontend/src/pages/Billing.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Receipt, Download, Trash2, Plus, X } from 'lucide-react' import { toast } from 'sonner' import { - getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, getInvoicePdfUrl, + getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf, type Invoice, type InvoiceCreate, } from '../api/billing' @@ -196,15 +196,13 @@ export default function BillingPage() { {formatDate(inv.due_at)} {formatCurrency(inv.total_net, inv.currency)} - downloadInvoicePdf(inv.id).catch(() => toast.error('PDF download failed'))} className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors" title="Download PDF" > - + {inv.status === 'draft' && ( { diff --git a/frontend/src/pages/CadPreview.tsx b/frontend/src/pages/CadPreview.tsx index aa07a19..e771b77 100644 --- a/frontend/src/pages/CadPreview.tsx +++ b/frontend/src/pages/CadPreview.tsx @@ -1,24 +1,30 @@ +import { useState, useEffect } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useMutation } from '@tanstack/react-query' +import { Box, Loader2, X } from 'lucide-react' import ThreeDViewer from '../components/cad/ThreeDViewer' import { getMediaAssets } from '../api/media' +import { generateGltfGeometry } from '../api/cad' /** * Route: /cad/:id * * Full-screen 3D viewer for a CAD file. - * Passes production GLB URL if a gltf_geometry MediaAsset exists for this CAD file. + * If no geometry GLB exists yet, offers to generate one on demand. */ export default function CadPreviewPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() + const [generating, setGenerating] = useState(false) // Load any geometry GLB that was generated for this CAD file - const { data: gltfAssets } = useQuery({ + // Poll every 3s while generating so it appears automatically + const { data: gltfAssets, isLoading: gltfLoading } = useQuery({ queryKey: ['media-assets', id, 'gltf_geometry'], queryFn: () => getMediaAssets({ cad_file_id: id!, asset_type: 'gltf_geometry' }), enabled: !!id, - staleTime: 30_000, + staleTime: 5_000, + refetchInterval: generating ? 3_000 : false, }) // Load production GLB if available @@ -37,6 +43,20 @@ export default function CadPreviewPage() { staleTime: 30_000, }) + const generateMutation = useMutation({ + mutationFn: () => generateGltfGeometry(id!), + onSuccess: () => { + setGenerating(true) + }, + }) + + // Stop polling once asset appears + useEffect(() => { + if (generating && gltfAssets && gltfAssets.length > 0) { + setGenerating(false) + } + }, [generating, gltfAssets]) + if (!id) { return ( @@ -49,6 +69,61 @@ export default function CadPreviewPage() { const latestProduction = productionAssets?.[0] const latestBlend = blendAssets?.[0] + // While checking for assets, show a neutral loading screen (don't attempt to render ThreeDViewer) + if (gltfLoading) { + return ( + + + Checking for 3D model… + + ) + } + + // No GLB available yet — show generate prompt + if (!latestGltf) { + return ( + + + 3D Viewer + navigate(-1)} + className="p-1.5 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors" + > + + + + + + No 3D model available yet + + Generate a GLB file from the STEP cache to enable the 3D viewer. + The STL cache must exist (process the STEP file first). + + {generating ? ( + + + Generating… checking every 3s + + ) : ( + generateMutation.mutate()} + disabled={generateMutation.isPending} + className="px-5 py-2 rounded-md bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors flex items-center gap-2" + > + {generateMutation.isPending && } + Generate 3D Model + + )} + {generateMutation.isError && ( + + Failed to start generation. Check that the STL cache exists. + + )} + + + ) + } + return ( = { blend_production: 'bg-pink-100 text-pink-700', } -const ALL_TYPES: MediaAssetType[] = [ - 'thumbnail', 'still', 'turntable', - 'stl_low', 'stl_high', - 'gltf_geometry', 'gltf_production', 'blend_production', -] +const PRIMARY_TYPES: MediaAssetType[] = ['still', 'turntable', 'thumbnail'] +const ADVANCED_TYPES: MediaAssetType[] = ['gltf_geometry', 'gltf_production', 'blend_production', 'stl_low', 'stl_high'] +const ALL_TYPES: MediaAssetType[] = [...PRIMARY_TYPES, ...ADVANCED_TYPES] +const DEFAULT_TYPES: Set = new Set(['still', 'turntable']) const isImageAsset = (type: MediaAssetType) => type === 'thumbnail' || type === 'still' const isVideoAsset = (type: MediaAssetType) => type === 'turntable' @@ -82,6 +81,22 @@ function AssetCard({ alt={asset.asset_type} className="w-full h-40 object-cover bg-gray-50" /> + ) : isVideoAsset(asset.asset_type) && asset.download_url ? ( + (e.currentTarget as HTMLVideoElement).play()} + onMouseLeave={e => { (e.currentTarget as HTMLVideoElement).pause(); (e.currentTarget as HTMLVideoElement).currentTime = 0 }} + /> + ) : asset.thumbnail_url ? ( + ) : ( @@ -169,13 +184,23 @@ export default function MediaBrowserPage() { const qc = useQueryClient() const [view, setView] = useState<'grid' | 'list'>('grid') - const [assetType, setAssetType] = useState('') + const [activeTypes, setActiveTypes] = useState>(new Set(DEFAULT_TYPES)) + const [showAdvanced, setShowAdvanced] = useState(false) const [productIdInput, setProductIdInput] = useState('') const [page, setPage] = useState(0) const [selectedIds, setSelectedIds] = useState>(new Set()) + const toggleType = (t: MediaAssetType) => { + setActiveTypes(prev => { + const next = new Set(prev) + next.has(t) ? next.delete(t) : next.add(t) + return next + }) + setPage(0) + } + const filter: MediaFilter = { - asset_type: assetType || undefined, + asset_types: activeTypes.size > 0 ? [...activeTypes] : ALL_TYPES, product_id: productIdInput.trim() || undefined, skip: page * PAGE_SIZE, limit: PAGE_SIZE, @@ -266,29 +291,59 @@ export default function MediaBrowserPage() { {/* Filters */} - - - - { setProductIdInput(e.target.value); setPage(0) }} - className="pl-8 pr-3 py-2 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent w-64" - /> - - { setAssetType(e.target.value as MediaAssetType | ''); setPage(0) }} - className="px-3 py-2 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent" - > - All types - {ALL_TYPES.map(t => ( - {t} + + + + + { setProductIdInput(e.target.value); setPage(0) }} + className="pl-8 pr-3 py-1.5 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent w-56" + /> + + {/* Primary type chips */} + {PRIMARY_TYPES.map(t => ( + toggleType(t)} + className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${ + activeTypes.has(t) + ? `${TYPE_COLORS[t]} border-transparent` + : 'bg-gray-50 text-gray-400 border-gray-200 hover:border-gray-300' + }`} + > + {t} + ))} - - {selectedIds.size > 0 && ( - {selectedIds.size} selected + setShowAdvanced(v => !v)} + className="flex items-center gap-1 px-3 py-1 text-xs text-content-secondary border border-border-default rounded-full hover:bg-surface-hover transition-colors" + > + Advanced + {showAdvanced ? : } + + {selectedIds.size > 0 && ( + {selectedIds.size} selected + )} + + {showAdvanced && ( + + {ADVANCED_TYPES.map(t => ( + toggleType(t)} + className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${ + activeTypes.has(t) + ? `${TYPE_COLORS[t]} border-transparent` + : 'bg-gray-50 text-gray-400 border-gray-200 hover:border-gray-300' + }`} + > + {t} + + ))} + )} diff --git a/plan.md b/plan.md index 7ae7a7c..6d79f78 100644 --- a/plan.md +++ b/plan.md @@ -1,6 +1,12 @@ -# Plan: UI-Vollständigkeit + Workflows — Phase O +# Plan: Layout Hamburger + Media Browser Fixes + Retroactive Import -**Ziel**: Alle implementierten Backend-Features im UI zugänglich machen + v3-Workflows vollständig verdrahten. +## Kontext + +Vier unabhängige Bereiche: +1. **Layout**: Sidebar hat kein Mobile-Support, kein Hamburger-Menü → Content füllt nicht volle Breite auf kleinen Screens +2. **Media Browser Previews**: glTF-Assets zeigen nur Icon-Placeholder; CadFile-Thumbnails wären als Preview nutzbar +3. **Media Browser Filter-Defaults**: Aktuell kein Default-Filter → alle Types (inkl. GLB/STL) sichtbar; gewünscht: Default nur still + turntable +4. **Retroactive Import**: Bestehende `cad_files.thumbnail_path` und `order_lines.result_path` sind nicht als `media_assets` erfasst --- @@ -8,232 +14,101 @@ | Datei | Änderung | |-------|----------| -| `frontend/src/components/layout/Layout.tsx` | Upload-Link hinzufügen | -| `frontend/src/pages/Admin.tsx` | OutputType-Tabelle: Workflow-Dropdown | -| `frontend/src/pages/AssetLibrary.tsx` | NEU: Asset Library Management UI | -| `frontend/src/api/asset_libraries.ts` | NEU: API-Client | -| `frontend/src/pages/ProductDetail.tsx` | Mesh-Attribute-Anzeige | -| `frontend/src/pages/Upload.tsx` | Sanity-Check-Dialog nach Import | -| `frontend/src/api/imports.ts` | NEU: import_validation API | -| `frontend/src/App.tsx` | Route /asset-libraries | -| `backend/app/api/routers/notification_configs.py` | NEU: notification_configs CRUD | -| `backend/app/main.py` | notification_configs router registrieren | -| `backend/app/api/routers/orders.py` | dispatch_renders → dispatch_render_with_workflow | -| `backend/app/api/routers/output_types.py` | workflow_definition_id im PATCH | -| `backend/app/schemas/output_type.py` | workflow_definition_id im Schema | -| `backend/app/domains/rendering/tasks.py` | K3: apply_asset_library_materials_task | -| `backend/app/tasks/step_tasks.py` | OCC sharp edge extraction in render_step_thumbnail | -| `render-worker/scripts/still_render.py` | mark_sharp / UV seams support | -| `render-worker/scripts/blender_render.py` | mark_sharp / UV seams support | -| `backend/app/services/step_processor.py` | extract_mesh_edge_data() für sharp edges | +| `frontend/src/components/layout/Layout.tsx` | Hamburger-Menü + Mobile-Overlay | +| `frontend/src/pages/MediaBrowser.tsx` | Filter-Chips + Previews + Default-Filter | +| `frontend/src/api/media.ts` | `asset_types[]` statt `asset_type` + `thumbnail_url` Feld | +| `backend/app/domains/media/schemas.py` | `thumbnail_url: str | None` Feld | +| `backend/app/domains/media/router.py` | `asset_types` Multi-Query-Param + thumbnail_url befüllen | +| `backend/app/domains/media/service.py` | `get_thumbnail_url(asset)` Helper | +| `backend/app/api/routers/admin.py` | `POST /api/admin/import-media-assets` Endpoint | +| `frontend/src/pages/Admin.tsx` | Button "Import Existing Media" | --- -## Tasks +## Tasks (in Reihenfolge) -### Task 1: Upload-Link in Sidebar [QUICK WIN] +### Task 1: Layout — Hamburger-Menü + Mobile Sidebar - **Datei**: `frontend/src/components/layout/Layout.tsx` -- **Was**: `Upload`-Icon + NavLink zu `/upload` in der Sidebar für alle eingeloggten User -- **Akzeptanzkriterium**: Upload-Link sichtbar in Sidebar - -### Task 2: notification_configs Backend-Router [Phase I] -- **Datei**: `backend/app/api/routers/notification_configs.py` (NEU), `backend/app/main.py` -- **Was**: REST-Endpoints für `notification_configs` Tabelle (044 bereits migriert): - - `GET /api/notification-configs` — gibt configs für aktuellen User zurück (mit Defaults falls keine Zeilen) - - `PUT /api/notification-configs/{event_type}/{channel}` — setzt enabled=true/false - - `POST /api/notification-configs/reset` — löscht alle configs des Users → Defaults gelten wieder - - Response: `[{event_type, channel, enabled}]` - - Auth: `get_current_user` (jeder kann seine eigenen Configs verwalten) -- **Akzeptanzkriterium**: NotificationSettings.tsx zeigt Toggle-Matrix und speichert korrekt - -### Task 3: OutputType → WorkflowDefinition — Schema + API -- **Datei**: `backend/app/schemas/output_type.py`, `backend/app/api/routers/output_types.py` - **Was**: - - `OutputTypeOut` + `OutputTypePatch`: `workflow_definition_id: uuid.UUID | None` hinzufügen - - PATCH-Handler: `workflow_definition_id` setzen wenn in body - - `OutputTypeOut` soll `workflow_name: str | None` als convenience field enthalten -- **Akzeptanzkriterium**: `PATCH /api/output-types/{id}` mit `{"workflow_definition_id": "..."}` funktioniert + - State `sidebarOpen: boolean` (default: `false` auf mobile, `true` auf desktop via window.innerWidth) + - Hamburger-Button (`Menu`-Icon aus lucide) in einem mobilen Header-Bar (nur sichtbar `< md`, also `md:hidden`) + - Sidebar: auf mobile `fixed left-0 top-0 h-full z-40 transform transition-transform`, bei `sidebarOpen`: `translate-x-0`, sonst `-translate-x-full`; auf Desktop immer sichtbar (`md:relative md:translate-x-0`) + - Overlay-Backdrop: halbtransparentes `div` hinter Sidebar, nur auf mobile sichtbar wenn open, click schließt Sidebar + - Close-Button (X) oben in Sidebar auf mobile + - Content-Bereich: `flex-1 overflow-auto min-w-0` damit er immer volle restliche Breite nutzt +- **Akzeptanzkriterium**: Auf <768px Hamburger sichtbar, Sidebar aus-/einblendbar; auf ≥768px Sidebar immer sichtbar -### Task 4: Workflow-Dispatch Integration -- **Datei**: `backend/app/api/routers/orders.py` -- **Was**: In `dispatch_renders()` (Zeile 910): - - Statt `dispatch_order_line_render.delay(str(line.id))` aufrufen: - - `from app.domains.rendering.dispatch_service import dispatch_render_with_workflow` - - `dispatch_render_with_workflow(str(line.id))` aufrufen - - Das dispatch_service lädt OutputType.workflow_definition_id und nutzt Celery Canvas falls verknüpft; fällt auf Legacy zurück wenn nicht. -- **Akzeptanzkriterium**: Dispatch nutzt neuen Pfad; Legacy-Fallback bleibt erhalten - -### Task 5: Asset Library API-Client (Frontend) -- **Datei**: `frontend/src/api/asset_libraries.ts` (NEU) +### Task 2: Backend — `asset_types[]` Multi-Filter + `thumbnail_url` +- **Datei**: `backend/app/domains/media/router.py`, `backend/app/domains/media/schemas.py`, `backend/app/domains/media/service.py` - **Was**: - ```typescript - export interface AssetLibrary { id, name, description, original_filename, catalog: {materials: string[], node_groups: string[]}, is_active, created_at } - export async function listAssetLibraries(): Promise - export async function uploadAssetLibrary(name: string, file: File, description?: string): Promise - export async function refreshLibraryCatalog(id: string): Promise - export async function deleteAssetLibrary(id: string): Promise - export async function updateAssetLibrary(id: string, data: Partial): Promise - ``` -- **Akzeptanzkriterium**: TypeScript kompiliert fehlerfrei + - `list_assets` Endpoint: Zusätzlichen Query-Param `asset_types: list[MediaAssetType] = Query(default=[])` hinzufügen + - Filter-Logik: wenn `asset_types` nicht leer → `WHERE asset_type IN (asset_types)`; sonst wenn `asset_type` gesetzt → wie bisher + - `MediaAssetOut`: neues Feld `thumbnail_url: str | None = None` + - `service.py`: neue Funktion `get_thumbnail_url(asset) -> str | None` — gibt `/api/cad/{cad_file_id}/thumbnail` zurück wenn `cad_file_id` gesetzt (unabhängig von asset_type) + - In `list_assets` und `get_asset`: `a.thumbnail_url = service.get_thumbnail_url(a)` setzen (analog zu `download_url`) +- **Akzeptanzkriterium**: `GET /api/media/?asset_types=still&asset_types=turntable` gibt nur still+turntable zurück; jedes Asset mit `cad_file_id` hat `thumbnail_url` gesetzt -### Task 6: Asset Library Management Page (K2) -- **Datei**: `frontend/src/pages/AssetLibrary.tsx` (NEU) -- **Was**: Seite `/asset-libraries` (admin/PM): - - Liste der Asset Libraries als Karten: Name, Filename, Badge-Grid mit Materialien/Node-Groups aus `catalog` - - Upload-Button: Datei-Input für `.blend` + Name-Feld → `uploadAssetLibrary()` - - "Refresh Catalog" Button je Library → `refreshLibraryCatalog(id)` → Toast - - Toggle `is_active` → `updateAssetLibrary()` - - Delete-Button → `deleteAssetLibrary()` - - Leer-Zustand: "No asset libraries yet — upload a .blend file" -- **Akzeptanzkriterium**: Libraries hochladen, Katalog anzeigen, löschen - -### Task 7: Asset Library Route + Sidebar-Link -- **Datei**: `frontend/src/App.tsx`, `frontend/src/components/layout/Layout.tsx` +### Task 3: Frontend — Media Browser Filter-Chips + Previews +- **Datei**: `frontend/src/pages/MediaBrowser.tsx`, `frontend/src/api/media.ts` - **Was**: - - App.tsx: Route `/asset-libraries` → `` (AdminRoute) - - Layout.tsx: Sidebar-Link "Asset Libraries" mit `Library`-Icon (admin/PM) -- **Abhängigkeiten**: Task 6 + - `api/media.ts`: `MediaFilter.asset_types?: MediaAssetType[]` (statt `asset_type`); `getMediaAssets` sendet `asset_types` als repeated params; `MediaAsset` bekommt `thumbnail_url: string | null` + - `MediaBrowser.tsx`: + - State: `activeTypes: Set` — Default: `new Set(['still', 'turntable'])` + - Filter-UI: Chip-Grid mit allen Types; `still`/`turntable`/`thumbnail` in der Hauptreihe; `gltf_geometry`/`gltf_production`/`blend_production`/`stl_low`/`stl_high` hinter "Advanced" Toggle (collapsed by default) + - Chip aktiv = farbiger Hintergrund entsprechend `TYPE_COLORS`; inaktiv = grau + - Chip-Klick toggled den Type aus `activeTypes` + - `getMediaAssets({ asset_types: [...activeTypes], ... })` + - `AssetCard`: wenn `isImageAsset(type)` → `download_url`; wenn `thumbnail_url` vorhanden → `thumbnail_url` als Preview; sonst Icon + - Video-Assets (`turntable`): Video-Poster via `thumbnail_url` (falls vorhanden) mit ``-Tag anzeigen oder Bild +- **Akzeptanzkriterium**: Default zeigt nur still+turntable; Chip-Klick filtert korrekt; GLB-Assets zeigen CadFile-Thumbnail -### Task 8: OutputType Workflow-Dropdown (Frontend) -- **Datei**: `frontend/src/pages/Admin.tsx` (OutputTypeTable-Bereich) -- **Was**: In der OutputType-Tabelle eine neue Spalte "Workflow": - - Dropdown mit allen WorkflowDefinitions (aus `GET /api/workflows`) + "— None —" - - Bei Änderung: `PATCH /api/output-types/{id}` mit `{workflow_definition_id: ...}` - - Wenn kein Workflow: zeige "Legacy" Badge; wenn Workflow: zeige Workflow-Name als grünes Badge -- **Akzeptanzkriterium**: Workflow kann pro OutputType zugewiesen werden - -### Task 9: Excel Sanity-Check Backend (Phase H) -- **Datei**: `backend/app/domains/imports/sanity_check.py` (NEU), `backend/app/domains/imports/router.py` -- **Was**: - - Sync-Funktion `run_sanity_check(import_validation_id: str)`: - - Lädt ImportValidation-Record - - Iteriert über `rows` (ParsedRows aus Excel) - - Für jede Zeile: prüft ob `name_cad_modell` eine CadFile zugeordnet hat (`cad_files.original_name ILIKE`) - - Prüft ob `cad_part_materials` alle Materialien in `materials`-Tabelle (via Alias-Lookup) auflösbar sind - - Erstellt `summary: {total_rows, rows_with_cad, rows_without_cad, material_gaps: [{product, missing_material}]}` - - Status → 'completed' - - Celery-Task `validate_excel_import_task(import_validation_id)` Queue `step_processing` - - Endpoint `GET /api/imports/{id}/validation` — gibt ImportValidation zurück - - Endpoint `POST /api/imports/{id}/add-alias` — schnell einen Alias hinzufügen (part_name → material) - - ImportValidation DB-Zugriif: sync SQLAlchemy (Celery-kompatibel) -- **Akzeptanzkriterium**: Nach Excel-Upload wird Import-Validierung automatisch gequeuet; `summary` liefert Material-Lücken - -### Task 10: Upload.tsx — Sanity-Check-Dialog (Phase H) -- **Datei**: `frontend/src/pages/Upload.tsx` -- **Was**: Nach erfolgreichem Excel-Upload: - - `GET /api/imports/{id}/validation` pollen (alle 3s, max 30s) - - Wenn status='completed': Ampel-Dialog anzeigen: - - Grün-Badge: "X Produkte mit STEP-Datei" - - Gelb-Badge: "Y Produkte ohne STEP-Datei" - - Rote Liste: Material-Lücken (Part-Name → fehlendes Material, mit "Add Alias" Button) - - "Proceed" Button schließt Dialog - - Import API erweitern: `api/imports.ts` mit `getImportValidation(id)`, `addMaterialAlias()` -- **Akzeptanzkriterium**: Nach Upload erscheint Dialog mit Produktions-Readiness - -### Task 11: Mesh-Attribute Anzeige in ProductDetail (Phase D) -- **Datei**: `frontend/src/pages/ProductDetail.tsx` -- **Was**: Im CAD-File-Bereich, nach dem Status-Badge: - - Wenn `product.cad_file.mesh_attributes` vorhanden: kleine Info-Karte - - Felder: `volume_cm3` (aus `mesh_attributes.volume_mm3 / 1000` → "12.5 cm³"), - `surface_area_cm2`, `bounding_box` ("W×H×D mm"), `sharp_angle_deg` (aus `suggested_smooth_angle`) - - Label "Geometry" mit `Ruler`-Icon -- **API-Änderung**: Product-API gibt `cad_file.mesh_attributes` zurück (prüfen ob vorhanden) -- **Akzeptanzkriterium**: Volumen, Oberfläche, BBox in ProductDetail sichtbar (wenn vorhanden) - -### Task 12: OCC Edge-Analyse → mesh_attributes (Sharp/Seam) -- **Datei**: `backend/app/services/step_processor.py` -- **Was**: Neue Funktion `extract_mesh_edge_data(step_path: str) -> dict`: - - Öffnet STEP via OCC - - Iteriert über alle Faces und deren Edges - - Berechnet Winkel zwischen adjazenten Faces per Edge (Dihedralwinkel) - - Sammelt: - - `suggested_smooth_angle`: Median-Winkel aller Kanten wo Winkel > 5° (typisch 30–60°) - - `has_mechanical_edges`: bool (True wenn mehrere Kanten mit Winkel > 60° → Lagerkante) - - `sharp_edge_midpoints`: Liste von `[x,y,z]` mm-Koordinaten der scharfen Kanten-Mittelpunkte (max 500 Stück, für Winkel > 45°) - - Integriert in `extract_cad_metadata()`: nach `_extract_step_objects()` aufrufen, Ergebnis in `mesh_attributes` mergen - - Fallback: bei OCC-Fehler gracefully `{}` zurückgeben -- **Akzeptanzkriterium**: `cad_files.mesh_attributes` enthält `suggested_smooth_angle` nach Verarbeitung - -### Task 13: Blender-Scripts — mark_sharp + UV-Seams -- **Dateien**: `render-worker/scripts/still_render.py`, `render-worker/scripts/blender_render.py` -- **Was**: Nach STL-Import, vor dem Render: - 1. Wenn `mesh_attributes.suggested_smooth_angle` vorhanden: diesen Winkel statt globalem `smooth_angle` nutzen - 2. Neue Funktion `_mark_sharp_edges(obj, smooth_angle_deg, sharp_edge_midpoints=None)`: - - Setzt `obj.data.auto_smooth_angle = math.radians(smooth_angle_deg)` - - Wählt Kanten aus: `bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(smooth_angle_deg))` - - Ruft `bpy.ops.mesh.mark_sharp()` auf - - Wenn `sharp_edge_midpoints` vorhanden: KD-Tree matching → zusätzliche Kanten markieren - 3. Neue Funktion `_create_uv_seams_from_sharps(obj)`: - - Startet Edit-Mode - - Selektiert alle Sharp-Kanten: `[e for e in mesh.edges if e.use_edge_sharp]` - - Markiert diese als Seams: `edge.use_seam = True` - - Ruft `bpy.ops.uv.smart_project(angle_limit=math.radians(smooth_angle_deg))` auf - 4. Beide Funktionen nach `_import_stl()` aufrufen (Mode A + Mode B) -- **Akzeptanzkriterium**: Gerenderte Bilder zeigen korrekte Kanten für Lager (30° Winkel scharf sichtbar) - -### Task 14: K3 — apply_asset_library_materials_task -- **Datei**: `backend/app/domains/rendering/tasks.py` -- **Was**: Neuer Celery-Task: +### Task 4: Backend — Retroactive MediaAsset Import Endpoint +- **Datei**: `backend/app/api/routers/admin.py` +- **Was**: Neuer Endpoint `POST /api/admin/import-media-assets` (require_admin): ```python - @celery_app.task(name="...apply_asset_library_materials_task", queue="thumbnail_rendering") - def apply_asset_library_materials_task(order_line_id: str, asset_library_id: str) -> dict: - # Lädt OrderLine, CadFile, AssetLibrary - # Prüft ob asset_library.blend_file_path existiert - # Ruft Blender subprocess auf mit asset_library.py: - # blender --background --python asset_library.py -- --stl_path X --asset_library_blend Y --material_map '{...}' - # Returns {'status': 'applied', 'materials_count': N} - ``` - Skript `render-worker/scripts/asset_library.py` existiert bereits. -- **Akzeptanzkriterium**: Task läuft ohne Fehler wenn Blender verfügbar + # 1. CadFiles mit thumbnail_path + status='completed' + SELECT id, thumbnail_path FROM cad_files + WHERE thumbnail_path IS NOT NULL AND status = 'completed' -### Task 15: K4/K5 — export_gltf + export_blend via Blender -- **Datei**: `backend/app/domains/rendering/tasks.py` -- **Was**: `export_gltf_for_order_line_task` und `export_blend_for_order_line_task` überarbeiten: - - Statt trimesh: Blender subprocess mit `export_gltf.py` / `export_blend.py` - - Asset Library path aus LinkedAssetLibrary (via OutputType) übergeben falls vorhanden - - GLB → MinIO `production-exports/{cad_file_id}/{order_line_id}.glb` - - .blend → MinIO `production-exports/{cad_file_id}/{order_line_id}.blend` - - MediaAsset erstellen mit `gltf_production` / `blend_production` type -- **Akzeptanzkriterium**: Export-Tasks produzieren GLB/BLEND-Dateien in MinIO + # 2. OrderLines mit result_path + render_status='completed' + output_type + SELECT ol.id, ol.result_path, ol.product_id, ol.output_type_id, ot.is_animation + FROM order_lines ol LEFT JOIN output_types ot ON ot.id = ol.output_type_id + WHERE ol.result_path IS NOT NULL AND ol.render_status = 'completed' + ``` + - De-dup: `SELECT id FROM media_assets WHERE storage_key = ?` vor jedem Insert + - CadFile → `MediaAsset(asset_type='thumbnail', cad_file_id=..., storage_key=thumbnail_path, mime_type='image/jpeg')` + - OrderLine → `MediaAsset(asset_type='turntable' if is_animation else 'still', order_line_id=..., storage_key=result_path)` + - Returns: `{"created": N, "skipped": N}` +- **Akzeptanzkriterium**: Nach Aufruf erscheinen alle bestehenden Thumbnails + Renders im Media Browser + +### Task 5: Frontend — Admin "Import Existing Media" Button +- **Datei**: `frontend/src/pages/Admin.tsx` +- **Was**: Im Admin-Panel (Media/Settings-Bereich) neuer Button "Import Existing Media" → `POST /api/admin/import-media-assets` → Toast mit `{created, skipped}` Ergebnis +- **Abhängigkeiten**: Task 4 +- **Akzeptanzkriterium**: Button klickbar, zeigt Ergebnis --- -## Abhängigkeiten - -``` -Sofort (parallel): - Task 1 (Upload Link) - Task 2 (Notification Config Backend) - Task 3 (OutputType Schema) - Task 5 (Asset Library API) - Task 9 (Sanity Check Backend) - Task 12 (OCC Edge Analyse) - -Nach Task 3: - Task 4 (Dispatch Integration) - Task 8 (OutputType Workflow Dropdown) - -Nach Task 5+6: - Task 6 (Asset Library Page) — braucht Task 5 - Task 7 (Route + Sidebar) — braucht Task 6 - -Nach Task 9: - Task 10 (Upload Sanity Dialog) - -Nach Task 11: - Task 11 (Mesh Display) — unabhängig - -Nach Task 12: - Task 13 (Blender Scripts) - -Nach Task 14: - Task 15 (K4/K5 Exports) -``` - ## Migrations-Check -Alle benötigten Migrationen existieren bereits: -- 043: import_validations ✅ -- 044: notification_configs ✅ -- 045: asset_libraries ✅ -Keine neue Migration nötig. +Keine neue Migration nötig — alle Felder bereits vorhanden. + +--- + +## Reihenfolge-Empfehlung + +Task 1 (Layout) + Task 2 (Backend) parallel → +Task 3 (Frontend MediaBrowser, braucht Task 2) + Task 4 (Backend Admin) parallel → +Task 5 (Frontend Admin Button, braucht Task 4) + +Tasks 1 + 2 + 4 können vollständig parallel implementiert werden. +Task 3 + 5 können dann parallel implementiert werden. + +--- + +## Risiken / Offene Fragen + +- `thumbnail_url` für GLBs zeigt immer das CadFile-Thumbnail — das ist korrekt (kein spezifisches Render vorhanden) +- `result_path` bei OrderLines kann Pfad zu PNG oder MP4 sein — kein Media-Type prüfen, einfach MIME aus Extension ableiten +- Bestehende `thumbnail_path` Werte sind absolute Paths (`/app/uploads/...`) — gleicher Proxy-Mechanismus wie bei GLBs nötig (der download endpoint kann damit umgehen) +- Video-Preview (turntable): `` Tag mit `thumbnail_url` als Poster + `download_url` als src — falls download_url MP4 ist diff --git a/render-worker/Dockerfile b/render-worker/Dockerfile index e3e2926..c0e567b 100644 --- a/render-worker/Dockerfile +++ b/render-worker/Dockerfile @@ -61,6 +61,9 @@ RUN pip3 install --no-cache-dir -e . # Install cadquery (heavy — installed after backend deps for better layer caching) RUN pip3 install --no-cache-dir "cadquery>=2.4.0" +# Install trimesh for STL→GLB geometry export (separate layer to avoid cache invalidation) +RUN pip3 install --no-cache-dir "trimesh>=4.2.0" + # Copy render scripts COPY render-worker/scripts/ /render-scripts/
Re-renders thumbnails for all completed CAD files.
Registers existing renders & CAD thumbnails in the Media Browser.
Checking for 3D model…
No 3D model available yet
+ Generate a GLB file from the STEP cache to enable the 3D viewer. + The STL cache must exist (process the STEP file first). +
+ Failed to start generation. Check that the STL cache exists. +