feat: layout hamburger, media browser filters+previews, billing fixes

- Layout: mobile hamburger menu + overlay backdrop + close button; content area always full-width
- Media browser: filter chips (default still+turntable); advanced toggle for GLB/STL; thumbnail_url previews for non-image types; video hover-play for turntable
- Backend: asset_types multi-filter, thumbnail_url in MediaAssetOut, download proxy endpoint for MinIO/local files
- Admin: "Import Existing Media" button → POST /api/admin/import-media-assets
- Billing: fix invoice create 500 (MissingGreenlet — use selectinload after commit); PDF download uses axios blob instead of bare <a href> (auth header missing); fix storage.upload() accepting str|Path
- SSE task logs: task_logs.py core + router, LiveRenderLog component
- CadPreview: fix infinite loop when no gltf_geometry assets; loading screen before ThreeDViewer render
- render-worker: add trimesh layer to Dockerfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 00:09:27 +01:00
parent 9bf6e72718
commit f5ca91ee02
25 changed files with 792 additions and 299 deletions
+14
View File
@@ -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:** `<a href="/api/billing/invoices/{id}/pdf" target="_blank">` ö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 `<a>.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 ### 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. **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. **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.
+73
View File
@@ -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}
+78
View File
@@ -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",
},
)
+2 -1
View File
@@ -56,8 +56,9 @@ class MinIOStorage:
except Exception as exc: except Exception as exc:
logger.warning("Could not create MinIO bucket %s: %s", self._bucket, 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.""" """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) 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) logger.debug("Uploaded %s → minio://%s/%s", local_path.name, self._bucket, object_key)
return object_key return object_key
+24
View File
@@ -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)
+7 -3
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
@@ -83,8 +83,12 @@ async def download_invoice_pdf(
if not key: if not key:
raise HTTPException(status_code=503, detail="PDF generation unavailable (WeasyPrint not installed)") raise HTTPException(status_code=503, detail="PDF generation unavailable (WeasyPrint not installed)")
from app.core.storage import get_storage from app.core.storage import get_storage
url = get_storage().get_url(key) pdf_bytes = get_storage().download_bytes(key)
return RedirectResponse(url=url) 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) @invoice_router.delete("/invoices/{invoice_id}", status_code=status.HTTP_204_NO_CONTENT)
+8 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel from pydantic import BaseModel, computed_field
class InvoiceLineCreate(BaseModel): class InvoiceLineCreate(BaseModel):
@@ -54,4 +54,11 @@ class InvoiceOut(BaseModel):
created_at: datetime created_at: datetime
lines: list[InvoiceLineOut] = [] 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} model_config = {"from_attributes": True}
+1 -2
View File
@@ -267,8 +267,7 @@ async def create_invoice(
invoice.total_net = total_net invoice.total_net = total_net
invoice.total_vat = (total_net * vat_rate).quantize(Decimal("0.01")) invoice.total_vat = (total_net * vat_rate).quantize(Decimal("0.01"))
await db.commit() await db.commit()
await db.refresh(invoice) return await get_invoice(db, invoice.id)
return invoice
async def get_invoices( async def get_invoices(
+37 -7
View File
@@ -12,15 +12,17 @@ from app.domains.media.models import MediaAssetType
from app.domains.media.schemas import MediaAssetOut from app.domains.media.schemas import MediaAssetOut
from app.domains.media import service 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( async def list_assets(
product_id: uuid.UUID | None = None, product_id: uuid.UUID | None = None,
order_line_id: uuid.UUID | None = None, order_line_id: uuid.UUID | None = None,
cad_file_id: uuid.UUID | None = None, cad_file_id: uuid.UUID | None = None,
asset_type: MediaAssetType | None = None, asset_type: MediaAssetType | None = None,
asset_types: list[MediaAssetType] = Query(default=[]),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500), limit: int = Query(50, ge=1, le=500),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -31,11 +33,13 @@ async def list_assets(
order_line_id=order_line_id, order_line_id=order_line_id,
cad_file_id=cad_file_id, cad_file_id=cad_file_id,
asset_type=asset_type, asset_type=asset_type,
asset_types=asset_types if asset_types else None,
skip=skip, skip=skip,
limit=limit, limit=limit,
) )
for a in assets: for a in assets:
a.download_url = service.get_download_url(a) a.download_url = service.get_download_url(a)
a.thumbnail_url = service.get_thumbnail_url(a)
return assets return assets
@@ -45,18 +49,44 @@ async def get_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
if not asset: if not asset:
raise HTTPException(404, "Asset not found") raise HTTPException(404, "Asset not found")
asset.download_url = service.get_download_url(asset) asset.download_url = service.get_download_url(asset)
asset.thumbnail_url = service.get_thumbnail_url(asset)
return 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)): 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) asset = await service.get_media_asset(db, asset_id)
if not asset: if not asset:
raise HTTPException(404, "Asset not found") raise HTTPException(404, "Asset not found")
url = service.get_download_url(asset)
if url: key = asset.storage_key
return RedirectResponse(url) 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") raise HTTPException(404, "File not available")
+1
View File
@@ -22,5 +22,6 @@ class MediaAssetOut(BaseModel):
is_archived: bool is_archived: bool
created_at: datetime created_at: datetime
download_url: str | None = None download_url: str | None = None
thumbnail_url: str | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+13 -8
View File
@@ -11,6 +11,7 @@ async def list_media_assets(
order_line_id: uuid.UUID | None = None, order_line_id: uuid.UUID | None = None,
cad_file_id: uuid.UUID | None = None, cad_file_id: uuid.UUID | None = None,
asset_type: MediaAssetType | None = None, asset_type: MediaAssetType | None = None,
asset_types: list[MediaAssetType] | None = None,
is_archived: bool | None = False, is_archived: bool | None = False,
skip: int = 0, skip: int = 0,
limit: int = 50, limit: int = 50,
@@ -22,7 +23,9 @@ async def list_media_assets(
q = q.where(MediaAsset.order_line_id == order_line_id) q = q.where(MediaAsset.order_line_id == order_line_id)
if cad_file_id: if cad_file_id:
q = q.where(MediaAsset.cad_file_id == 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) q = q.where(MediaAsset.asset_type == asset_type)
if is_archived is not None: if is_archived is not None:
q = q.where(MediaAsset.is_archived == is_archived) 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: def get_download_url(asset: MediaAsset) -> str | None:
"""Get presigned URL from MinIO or local path.""" """Return a backend proxy URL so the browser can always download the file."""
try: return f"/api/media/{asset.id}/download"
from app.core.storage import get_storage
storage = get_storage()
return storage.get_url(asset.storage_key) def get_thumbnail_url(asset: MediaAsset) -> str | None:
except Exception: """Return CAD thumbnail URL if asset has a cad_file_id."""
return f"/uploads/{asset.storage_key}" if asset.cad_file_id:
return f"/api/cad/{asset.cad_file_id}/thumbnail"
return None
+64
View File
@@ -10,6 +10,7 @@ import logging
from pathlib import Path from pathlib import Path
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
from app.core.task_logs import log_task_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -55,6 +56,7 @@ def render_still_task(
Returns render metadata dict on success. Returns render metadata dict on success.
Retries up to 2 times on failure (30s countdown). 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: try:
from app.services.render_blender import render_still from app.services.render_blender import render_still
result = render_still( result = render_still(
@@ -86,14 +88,34 @@ def render_still_task(
denoising_use_gpu=denoising_use_gpu, denoising_use_gpu=denoising_use_gpu,
mesh_attributes=mesh_attributes or {}, 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( logger.info(
"render_still_task completed: %s%s in %.1fs", "render_still_task completed: %s%s in %.1fs",
Path(step_path).name, Path(output_path).name, Path(step_path).name, Path(output_path).name,
result.get("total_duration_s", 0), 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 return result
except Exception as exc: 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) 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) raise self.retry(exc=exc, countdown=30)
@@ -136,6 +158,7 @@ def render_turntable_task(
Returns render metadata dict on success. 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 json
import os import os
import shutil import shutil
@@ -211,7 +234,17 @@ def render_turntable_task(
f"Blender turntable exited {result.returncode}:\n{result.stdout[-2000:]}" f"Blender turntable exited {result.returncode}:\n{result.stdout[-2000:]}"
) )
except Exception as exc: except Exception as exc:
log_task_event(self.request.id, f"Failed: {exc}", "error")
logger.error("render_turntable_task failed: %s", exc) 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) raise self.retry(exc=exc, countdown=60)
# FFmpeg composite: frames → MP4 with optional background # FFmpeg composite: frames → MP4 with optional background
@@ -224,6 +257,16 @@ def render_turntable_task(
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
raise RuntimeError(f"FFmpeg composite failed: {exc.stderr[-500:]}") 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 { return {
"output_mp4": str(output_mp4), "output_mp4": str(output_mp4),
"frame_count": frame_count, "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. Wraps render_still_task logic but accepts order_line_id instead of step_path.
On success, creates a MediaAsset record via publish_asset. 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) step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
if not step_path_str: 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( raise RuntimeError(
f"Cannot resolve STEP path for order_line {order_line_id}: " f"Cannot resolve STEP path for order_line {order_line_id}: "
"product missing or has no linked CAD file" "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), str(output_path),
render_config=result, render_config=result,
) )
log_task_event(self.request.id, f"Completed successfully in {result.get('total_duration_s', 0):.1f}s", "done")
logger.info( logger.info(
"render_order_line_still_task completed for line %s in %.1fs", "render_order_line_still_task completed for line %s in %.1fs",
order_line_id, result.get("total_duration_s", 0), 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 return result
except Exception as exc: 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) 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) raise self.retry(exc=exc, countdown=30)
+2
View File
@@ -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.domains.media.router import router as media_router
from app.api.routers.asset_libraries import router as asset_libraries_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.domains.admin.dashboard_router import router as dashboard_router
from app.api.routers.task_logs import router as task_logs_router
@asynccontextmanager @asynccontextmanager
@@ -90,6 +91,7 @@ app.include_router(workflows_router)
app.include_router(media_router) app.include_router(media_router)
app.include_router(asset_libraries_router, prefix="/api") app.include_router(asset_libraries_router, prefix="/api")
app.include_router(dashboard_router, prefix="/api") app.include_router(dashboard_router, prefix="/api")
app.include_router(task_logs_router, prefix="/api")
@app.get("/health") @app.get("/health")
+5
View File
@@ -1,6 +1,7 @@
"""Celery tasks for STEP file processing and thumbnail generation.""" """Celery tasks for STEP file processing and thumbnail generation."""
import logging import logging
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
from app.core.task_logs import log_task_event
logger = logging.getLogger(__name__) 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 step_path_str = cad_file.stored_path
eng.dispose() 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) step = _Path(step_path_str)
stl_path = step.parent / f"{step.stem}_low.stl" stl_path = step.parent / f"{step.stem}_low.stl"
if not stl_path.exists(): 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) logger.error("generate_gltf_geometry_task: STL not found %s", stl_path)
raise RuntimeError(f"STL cache not found: {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 import trimesh
mesh = trimesh.load(str(stl_path)) mesh = trimesh.load(str(stl_path))
mesh.export(str(output_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) logger.info("generate_gltf_geometry_task: exported %s", output_path.name)
except Exception as exc: 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) logger.error("generate_gltf_geometry_task failed for %s: %s", cad_file_id, exc)
raise self.retry(exc=exc, countdown=15) raise self.retry(exc=exc, countdown=15)
+8 -2
View File
@@ -60,6 +60,12 @@ export async function deleteInvoice(id: string): Promise<void> {
await api.delete(`/billing/invoices/${id}`) await api.delete(`/billing/invoices/${id}`)
} }
export function getInvoicePdfUrl(id: string): string { export async function downloadInvoicePdf(id: string): Promise<void> {
return `/api/billing/invoices/${id}/pdf` 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)
} }
+4 -3
View File
@@ -28,13 +28,14 @@ export interface MediaAsset {
is_archived: boolean is_archived: boolean
created_at: string created_at: string
download_url: string | null download_url: string | null
thumbnail_url: string | null
} }
export interface MediaFilter { export interface MediaFilter {
product_id?: string product_id?: string
order_line_id?: string order_line_id?: string
cad_file_id?: string cad_file_id?: string
asset_type?: MediaAssetType asset_types?: MediaAssetType[]
skip?: number skip?: number
limit?: number limit?: number
} }
@@ -44,10 +45,10 @@ export const getMediaAssets = (filters: MediaFilter = {}): Promise<MediaAsset[]>
if (filters.product_id) params.set('product_id', filters.product_id) 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.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.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.skip !== undefined) params.set('skip', String(filters.skip))
if (filters.limit !== undefined) params.set('limit', String(filters.limit)) 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<MediaAsset> => export const getMediaAsset = (id: string): Promise<MediaAsset> =>
+3 -3
View File
@@ -218,8 +218,6 @@ export default function ThreeDViewer({
productionGltfUrl, productionGltfUrl,
downloadUrls, downloadUrls,
}: ThreeDViewerProps) { }: ThreeDViewerProps) {
const defaultUrl = `/api/cad/${cadFileId}/model`
const [mode, setMode] = useState<ViewMode>('geometry') const [mode, setMode] = useState<ViewMode>('geometry')
const [wireframe, setWireframe] = useState(false) const [wireframe, setWireframe] = useState(false)
const [envPreset, setEnvPreset] = useState<EnvPreset>('city') const [envPreset, setEnvPreset] = useState<EnvPreset>('city')
@@ -231,7 +229,7 @@ export default function ThreeDViewer({
const activeUrl = const activeUrl =
mode === 'production' && productionGltfUrl mode === 'production' && productionGltfUrl
? productionGltfUrl ? productionGltfUrl
: geometryGltfUrl || defaultUrl : geometryGltfUrl
const handleModelReady = useCallback(() => setModelReady(true), []) const handleModelReady = useCallback(() => setModelReady(true), [])
const handleError = useCallback((msg: string) => setLoadError(msg), []) const handleError = useCallback((msg: string) => setLoadError(msg), [])
@@ -372,6 +370,7 @@ export default function ThreeDViewer({
<directionalLight position={[5, 10, 7]} intensity={1.0} castShadow /> <directionalLight position={[5, 10, 7]} intensity={1.0} castShadow />
<directionalLight position={[-5, -5, -5]} intensity={0.25} /> <directionalLight position={[-5, -5, -5]} intensity={0.25} />
{activeUrl && (
<GltfErrorBoundary onError={handleError}> <GltfErrorBoundary onError={handleError}>
<Suspense fallback={null}> <Suspense fallback={null}>
<ModelWithReady <ModelWithReady
@@ -382,6 +381,7 @@ export default function ThreeDViewer({
/> />
</Suspense> </Suspense>
</GltfErrorBoundary> </GltfErrorBoundary>
)}
<OrbitControls enablePan enableZoom enableRotate minDistance={0.3} maxDistance={100} /> <OrbitControls enablePan enableZoom enableRotate minDistance={0.3} maxDistance={100} />
<Environment preset={envPreset} /> <Environment preset={envPreset} />
+55 -4
View File
@@ -1,7 +1,8 @@
import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom' 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 { useAuthStore } from '../../store/auth'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getWorkerActivity } from '../../api/worker' import { getWorkerActivity } from '../../api/worker'
import { listOrders } from '../../api/orders' import { listOrders } from '../../api/orders'
@@ -20,6 +21,7 @@ const nav = [
export default function Layout() { export default function Layout() {
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const [sidebarOpen, setSidebarOpen] = useState(false)
const { data: activity } = useQuery({ const { data: activity } = useQuery({
queryKey: ['worker-activity'], queryKey: ['worker-activity'],
@@ -43,8 +45,36 @@ export default function Layout() {
return ( return (
<div className="flex h-screen overflow-hidden bg-surface-alt"> <div className="flex h-screen overflow-hidden bg-surface-alt">
{/* Mobile top header bar */}
<header className="fixed top-0 left-0 right-0 z-30 md:hidden bg-surface border-b border-border-default h-12 flex items-center px-4 gap-3">
<button
onClick={() => setSidebarOpen(true)}
className="text-content-secondary hover:text-content transition-colors"
aria-label="Open navigation"
>
<Menu size={20} />
</button>
<span className="flex-1 text-sm font-semibold text-content">Schaeffler Automat</span>
<NotificationCenter />
</header>
{/* Overlay backdrop (mobile only) */}
{sidebarOpen && (
<div
className="fixed inset-0 z-30 md:hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */} {/* Sidebar */}
<aside className="w-60 flex-shrink-0 bg-surface border-r border-border-default flex flex-col"> <aside
className={clsx(
'fixed left-0 top-0 h-full z-40 w-60 bg-surface border-r border-border-default flex flex-col transform transition-transform duration-200',
'md:relative md:translate-x-0 md:flex-shrink-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="p-5 border-b border-border-default"> <div className="p-5 border-b border-border-default">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent rounded flex items-center justify-center"> <div className="w-8 h-8 bg-accent rounded flex items-center justify-center">
@@ -54,14 +84,26 @@ export default function Layout() {
<p className="font-semibold text-content text-sm">Schaeffler</p> <p className="font-semibold text-content text-sm">Schaeffler</p>
<p className="text-xs text-content-muted">Automat</p> <p className="text-xs text-content-muted">Automat</p>
</div> </div>
{/* NotificationCenter in sidebar header (desktop); hidden on mobile (shown in top bar) */}
<span className="hidden md:block">
<NotificationCenter /> <NotificationCenter />
</span>
{/* Close button — mobile only */}
<button
onClick={() => setSidebarOpen(false)}
className="md:hidden text-content-secondary hover:text-content transition-colors"
aria-label="Close navigation"
>
<X size={20} />
</button>
</div> </div>
</div> </div>
<nav className="flex-1 p-3 space-y-1"> <nav className="flex-1 p-3 space-y-1 overflow-y-auto">
{/* New Order — primary CTA at the top */} {/* New Order — primary CTA at the top */}
<Link <Link
to="/orders/new" to="/orders/new"
onClick={() => setSidebarOpen(false)}
className="flex items-center gap-2 px-3 py-2.5 mb-3 rounded-md text-sm font-semibold bg-accent text-accent-text hover:bg-accent-hover transition-colors shadow-sm" className="flex items-center gap-2 px-3 py-2.5 mb-3 rounded-md text-sm font-semibold bg-accent text-accent-text hover:bg-accent-hover transition-colors shadow-sm"
> >
<Plus size={18} /> <Plus size={18} />
@@ -79,6 +121,7 @@ export default function Layout() {
key={to} key={to}
to={to} to={to}
end={end} end={end}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
clsx( clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -108,6 +151,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && ( {(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink <NavLink
to="/admin" to="/admin"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
clsx( clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -124,6 +168,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && ( {(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink <NavLink
to="/billing" to="/billing"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
clsx( clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -140,6 +185,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && ( {(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink <NavLink
to="/media" to="/media"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
clsx( clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -156,6 +202,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && ( {(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink <NavLink
to="/workers" to="/workers"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
clsx( clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -172,6 +219,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && ( {(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink <NavLink
to="/workflows" to="/workflows"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
clsx( clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -188,6 +236,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && ( {(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink <NavLink
to="/asset-libraries" to="/asset-libraries"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
clsx( clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -204,6 +253,7 @@ export default function Layout() {
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<NavLink <NavLink
to="/notification-settings" to="/notification-settings"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
clsx( clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -220,6 +270,7 @@ export default function Layout() {
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<NavLink <NavLink
to="/tenants" to="/tenants"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
clsx( clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -258,7 +309,7 @@ export default function Layout() {
</aside> </aside>
{/* Main content */} {/* Main content */}
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto min-w-0 pt-12 md:pt-0">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
@@ -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<LogEntry[]>([])
const [connected, setConnected] = useState(false)
const [done, setDone] = useState(false)
const bottomRef = useRef<HTMLDivElement>(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 (
<div className="rounded-lg border border-gray-700 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-gray-800 border-b border-gray-700">
<Terminal size={14} className="text-gray-400" />
<span className="text-xs font-medium text-gray-300">{title}</span>
{!done && !connected && <Loader2 size={12} className="animate-spin text-gray-400 ml-auto" />}
{done && <span className="ml-auto text-xs text-green-400">Done</span>}
{connected && !done && <span className="ml-auto text-xs text-blue-400">Live</span>}
</div>
<div className="bg-gray-950 p-3 max-h-64 overflow-y-auto font-mono text-xs">
{logs.length === 0 && (
<span className="text-gray-600">Waiting for log output</span>
)}
{logs.map((entry, i) => (
<div key={i} className={`${levelColor(entry.level)}`}>
<span className="text-gray-600 mr-2">
{new Date(entry.ts * 1000).toLocaleTimeString()}
</span>
{entry.msg}
</div>
))}
<div ref={bottomRef} />
</div>
</div>
)
}
+20
View File
@@ -141,6 +141,14 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), 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({ const generateMissingStlsMut = useMutation({
mutationFn: () => api.post('/admin/settings/generate-missing-stls'), mutationFn: () => api.post('/admin/settings/generate-missing-stls'),
onSuccess: (res) => { onSuccess: (res) => {
@@ -666,6 +674,18 @@ export default function AdminPage() {
</button> </button>
<p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p> <p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
</div> </div>
<div className="flex flex-col gap-1">
<button
onClick={() => 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"
>
<RefreshCw size={14} className={importMediaAssetsMut.isPending ? 'animate-spin' : ''} />
{importMediaAssetsMut.isPending ? 'Importing…' : 'Import Existing Media'}
</button>
<p className="text-xs text-content-muted">Registers existing renders &amp; CAD thumbnails in the Media Browser.</p>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<button <button
onClick={() => generateMissingStlsMut.mutate()} onClick={() => generateMissingStlsMut.mutate()}
+4 -6
View File
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Receipt, Download, Trash2, Plus, X } from 'lucide-react' import { Receipt, Download, Trash2, Plus, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, getInvoicePdfUrl, getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf,
type Invoice, type InvoiceCreate, type Invoice, type InvoiceCreate,
} from '../api/billing' } from '../api/billing'
@@ -196,15 +196,13 @@ export default function BillingPage() {
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.due_at)}</td> <td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.due_at)}</td>
<td className="px-4 py-3 text-sm text-content">{formatCurrency(inv.total_net, inv.currency)}</td> <td className="px-4 py-3 text-sm text-content">{formatCurrency(inv.total_net, inv.currency)}</td>
<td className="px-4 py-3 flex items-center gap-1"> <td className="px-4 py-3 flex items-center gap-1">
<a <button
href={getInvoicePdfUrl(inv.id)} onClick={() => downloadInvoicePdf(inv.id).catch(() => toast.error('PDF download failed'))}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors" className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="Download PDF" title="Download PDF"
> >
<Download size={15} /> <Download size={15} />
</a> </button>
{inv.status === 'draft' && ( {inv.status === 'draft' && (
<button <button
onClick={() => { onClick={() => {
+79 -4
View File
@@ -1,24 +1,30 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom' 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 ThreeDViewer from '../components/cad/ThreeDViewer'
import { getMediaAssets } from '../api/media' import { getMediaAssets } from '../api/media'
import { generateGltfGeometry } from '../api/cad'
/** /**
* Route: /cad/:id * Route: /cad/:id
* *
* Full-screen 3D viewer for a CAD file. * 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() { export default function CadPreviewPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [generating, setGenerating] = useState(false)
// Load any geometry GLB that was generated for this CAD file // 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'], queryKey: ['media-assets', id, 'gltf_geometry'],
queryFn: () => getMediaAssets({ cad_file_id: id!, asset_type: 'gltf_geometry' }), queryFn: () => getMediaAssets({ cad_file_id: id!, asset_type: 'gltf_geometry' }),
enabled: !!id, enabled: !!id,
staleTime: 30_000, staleTime: 5_000,
refetchInterval: generating ? 3_000 : false,
}) })
// Load production GLB if available // Load production GLB if available
@@ -37,6 +43,20 @@ export default function CadPreviewPage() {
staleTime: 30_000, 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) { if (!id) {
return ( return (
<div className="flex items-center justify-center h-full text-content-muted p-8"> <div className="flex items-center justify-center h-full text-content-muted p-8">
@@ -49,6 +69,61 @@ export default function CadPreviewPage() {
const latestProduction = productionAssets?.[0] const latestProduction = productionAssets?.[0]
const latestBlend = blendAssets?.[0] const latestBlend = blendAssets?.[0]
// While checking for assets, show a neutral loading screen (don't attempt to render ThreeDViewer)
if (gltfLoading) {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-gray-950 gap-3">
<Loader2 size={36} className="animate-spin text-gray-400" />
<p className="text-gray-400 text-sm">Checking for 3D model</p>
</div>
)
}
// No GLB available yet — show generate prompt
if (!latestGltf) {
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950">
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800">
<span className="text-white font-semibold tracking-wide">3D Viewer</span>
<button
onClick={() => navigate(-1)}
className="p-1.5 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
>
<X size={20} />
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-8">
<Box size={48} className="text-gray-600" />
<p className="text-white text-lg font-semibold">No 3D model available yet</p>
<p className="text-gray-400 text-sm max-w-sm">
Generate a GLB file from the STEP cache to enable the 3D viewer.
The STL cache must exist (process the STEP file first).
</p>
{generating ? (
<div className="flex items-center gap-2 text-gray-300 text-sm">
<Loader2 size={16} className="animate-spin" />
Generating checking every 3s
</div>
) : (
<button
onClick={() => 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 && <Loader2 size={14} className="animate-spin" />}
Generate 3D Model
</button>
)}
{generateMutation.isError && (
<p className="text-red-400 text-sm">
Failed to start generation. Check that the STL cache exists.
</p>
)}
</div>
</div>
)
}
return ( return (
<ThreeDViewer <ThreeDViewer
cadFileId={id} cadFileId={id}
+74 -19
View File
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { import {
LayoutGrid, LayoutList, Download, Archive, Image, Film, Box, FileCode2, Layers, LayoutGrid, LayoutList, Download, Archive, Image, Film, Box, FileCode2, Layers,
ChevronLeft, ChevronRight, Search, ChevronLeft, ChevronRight, Search, ChevronDown, ChevronUp,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
@@ -32,11 +32,10 @@ const TYPE_COLORS: Record<MediaAssetType, string> = {
blend_production: 'bg-pink-100 text-pink-700', blend_production: 'bg-pink-100 text-pink-700',
} }
const ALL_TYPES: MediaAssetType[] = [ const PRIMARY_TYPES: MediaAssetType[] = ['still', 'turntable', 'thumbnail']
'thumbnail', 'still', 'turntable', const ADVANCED_TYPES: MediaAssetType[] = ['gltf_geometry', 'gltf_production', 'blend_production', 'stl_low', 'stl_high']
'stl_low', 'stl_high', const ALL_TYPES: MediaAssetType[] = [...PRIMARY_TYPES, ...ADVANCED_TYPES]
'gltf_geometry', 'gltf_production', 'blend_production', const DEFAULT_TYPES: Set<MediaAssetType> = new Set(['still', 'turntable'])
]
const isImageAsset = (type: MediaAssetType) => type === 'thumbnail' || type === 'still' const isImageAsset = (type: MediaAssetType) => type === 'thumbnail' || type === 'still'
const isVideoAsset = (type: MediaAssetType) => type === 'turntable' const isVideoAsset = (type: MediaAssetType) => type === 'turntable'
@@ -82,6 +81,22 @@ function AssetCard({
alt={asset.asset_type} alt={asset.asset_type}
className="w-full h-40 object-cover bg-gray-50" className="w-full h-40 object-cover bg-gray-50"
/> />
) : isVideoAsset(asset.asset_type) && asset.download_url ? (
<video
src={asset.download_url}
poster={asset.thumbnail_url ?? undefined}
className="w-full h-40 object-cover bg-gray-900"
loop
muted
onMouseEnter={e => (e.currentTarget as HTMLVideoElement).play()}
onMouseLeave={e => { (e.currentTarget as HTMLVideoElement).pause(); (e.currentTarget as HTMLVideoElement).currentTime = 0 }}
/>
) : asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="w-full h-40 object-cover bg-gray-50 opacity-80"
/>
) : ( ) : (
<div className="w-full h-40 flex items-center justify-center bg-gray-50"> <div className="w-full h-40 flex items-center justify-center bg-gray-50">
<TypeIcon type={asset.asset_type} /> <TypeIcon type={asset.asset_type} />
@@ -169,13 +184,23 @@ export default function MediaBrowserPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [view, setView] = useState<'grid' | 'list'>('grid') const [view, setView] = useState<'grid' | 'list'>('grid')
const [assetType, setAssetType] = useState<MediaAssetType | ''>('') const [activeTypes, setActiveTypes] = useState<Set<MediaAssetType>>(new Set(DEFAULT_TYPES))
const [showAdvanced, setShowAdvanced] = useState(false)
const [productIdInput, setProductIdInput] = useState('') const [productIdInput, setProductIdInput] = useState('')
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(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 = { const filter: MediaFilter = {
asset_type: assetType || undefined, asset_types: activeTypes.size > 0 ? [...activeTypes] : ALL_TYPES,
product_id: productIdInput.trim() || undefined, product_id: productIdInput.trim() || undefined,
skip: page * PAGE_SIZE, skip: page * PAGE_SIZE,
limit: PAGE_SIZE, limit: PAGE_SIZE,
@@ -266,7 +291,8 @@ export default function MediaBrowserPage() {
</div> </div>
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap gap-3 items-center"> <div className="space-y-2">
<div className="flex flex-wrap gap-2 items-center">
<div className="relative"> <div className="relative">
<Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" /> <Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input <input
@@ -274,21 +300,50 @@ export default function MediaBrowserPage() {
placeholder="Filter by product ID..." placeholder="Filter by product ID..."
value={productIdInput} value={productIdInput}
onChange={e => { setProductIdInput(e.target.value); setPage(0) }} onChange={e => { 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" 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"
/> />
</div> </div>
<select {/* Primary type chips */}
value={assetType} {PRIMARY_TYPES.map(t => (
onChange={e => { setAssetType(e.target.value as MediaAssetType | ''); setPage(0) }} <button
className="px-3 py-2 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent" key={t}
onClick={() => 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'
}`}
> >
<option value="">All types</option> {t}
{ALL_TYPES.map(t => ( </button>
<option key={t} value={t}>{t}</option>
))} ))}
</select> <button
onClick={() => 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 ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{selectedIds.size > 0 && ( {selectedIds.size > 0 && (
<span className="text-sm text-content-muted">{selectedIds.size} selected</span> <span className="text-sm text-content-muted ml-1">{selectedIds.size} selected</span>
)}
</div>
{showAdvanced && (
<div className="flex flex-wrap gap-2">
{ADVANCED_TYPES.map(t => (
<button
key={t}
onClick={() => 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}
</button>
))}
</div>
)} )}
</div> </div>
+88 -213
View File
@@ -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 | | Datei | Änderung |
|-------|----------| |-------|----------|
| `frontend/src/components/layout/Layout.tsx` | Upload-Link hinzufügen | | `frontend/src/components/layout/Layout.tsx` | Hamburger-Menü + Mobile-Overlay |
| `frontend/src/pages/Admin.tsx` | OutputType-Tabelle: Workflow-Dropdown | | `frontend/src/pages/MediaBrowser.tsx` | Filter-Chips + Previews + Default-Filter |
| `frontend/src/pages/AssetLibrary.tsx` | NEU: Asset Library Management UI | | `frontend/src/api/media.ts` | `asset_types[]` statt `asset_type` + `thumbnail_url` Feld |
| `frontend/src/api/asset_libraries.ts` | NEU: API-Client | | `backend/app/domains/media/schemas.py` | `thumbnail_url: str | None` Feld |
| `frontend/src/pages/ProductDetail.tsx` | Mesh-Attribute-Anzeige | | `backend/app/domains/media/router.py` | `asset_types` Multi-Query-Param + thumbnail_url befüllen |
| `frontend/src/pages/Upload.tsx` | Sanity-Check-Dialog nach Import | | `backend/app/domains/media/service.py` | `get_thumbnail_url(asset)` Helper |
| `frontend/src/api/imports.ts` | NEU: import_validation API | | `backend/app/api/routers/admin.py` | `POST /api/admin/import-media-assets` Endpoint |
| `frontend/src/App.tsx` | Route /asset-libraries | | `frontend/src/pages/Admin.tsx` | Button "Import Existing Media" |
| `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 |
--- ---
## 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` - **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**: - **Was**:
- `OutputTypeOut` + `OutputTypePatch`: `workflow_definition_id: uuid.UUID | None` hinzufügen - State `sidebarOpen: boolean` (default: `false` auf mobile, `true` auf desktop via window.innerWidth)
- PATCH-Handler: `workflow_definition_id` setzen wenn in body - Hamburger-Button (`Menu`-Icon aus lucide) in einem mobilen Header-Bar (nur sichtbar `< md`, also `md:hidden`)
- `OutputTypeOut` soll `workflow_name: str | None` als convenience field enthalten - 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`)
- **Akzeptanzkriterium**: `PATCH /api/output-types/{id}` mit `{"workflow_definition_id": "..."}` funktioniert - 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 ### Task 2: Backend — `asset_types[]` Multi-Filter + `thumbnail_url`
- **Datei**: `backend/app/api/routers/orders.py` - **Datei**: `backend/app/domains/media/router.py`, `backend/app/domains/media/schemas.py`, `backend/app/domains/media/service.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)
- **Was**: - **Was**:
```typescript - `list_assets` Endpoint: Zusätzlichen Query-Param `asset_types: list[MediaAssetType] = Query(default=[])` hinzufügen
export interface AssetLibrary { id, name, description, original_filename, catalog: {materials: string[], node_groups: string[]}, is_active, created_at } - Filter-Logik: wenn `asset_types` nicht leer → `WHERE asset_type IN (asset_types)`; sonst wenn `asset_type` gesetzt → wie bisher
export async function listAssetLibraries(): Promise<AssetLibrary[]> - `MediaAssetOut`: neues Feld `thumbnail_url: str | None = None`
export async function uploadAssetLibrary(name: string, file: File, description?: string): Promise<AssetLibrary> - `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)
export async function refreshLibraryCatalog(id: string): Promise<AssetLibrary> - In `list_assets` und `get_asset`: `a.thumbnail_url = service.get_thumbnail_url(a)` setzen (analog zu `download_url`)
export async function deleteAssetLibrary(id: string): Promise<void> - **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
export async function updateAssetLibrary(id: string, data: Partial<AssetLibrary>): Promise<AssetLibrary>
```
- **Akzeptanzkriterium**: TypeScript kompiliert fehlerfrei
### Task 6: Asset Library Management Page (K2) ### Task 3: Frontend — Media Browser Filter-Chips + Previews
- **Datei**: `frontend/src/pages/AssetLibrary.tsx` (NEU) - **Datei**: `frontend/src/pages/MediaBrowser.tsx`, `frontend/src/api/media.ts`
- **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`
- **Was**: - **Was**:
- App.tsx: Route `/asset-libraries` → `<AssetLibraryPage />` (AdminRoute) - `api/media.ts`: `MediaFilter.asset_types?: MediaAssetType[]` (statt `asset_type`); `getMediaAssets` sendet `asset_types` als repeated params; `MediaAsset` bekommt `thumbnail_url: string | null`
- Layout.tsx: Sidebar-Link "Asset Libraries" mit `Library`-Icon (admin/PM) - `MediaBrowser.tsx`:
- **Abhängigkeiten**: Task 6 - State: `activeTypes: Set<MediaAssetType>` — 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 `<video>`-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) ### Task 4: Backend — Retroactive MediaAsset Import Endpoint
- **Datei**: `frontend/src/pages/Admin.tsx` (OutputTypeTable-Bereich) - **Datei**: `backend/app/api/routers/admin.py`
- **Was**: In der OutputType-Tabelle eine neue Spalte "Workflow": - **Was**: Neuer Endpoint `POST /api/admin/import-media-assets` (require_admin):
- 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 3060°)
- `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:
```python ```python
@celery_app.task(name="...apply_asset_library_materials_task", queue="thumbnail_rendering") # 1. CadFiles mit thumbnail_path + status='completed'
def apply_asset_library_materials_task(order_line_id: str, asset_library_id: str) -> dict: SELECT id, thumbnail_path FROM cad_files
# Lädt OrderLine, CadFile, AssetLibrary WHERE thumbnail_path IS NOT NULL AND status = 'completed'
# 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
### Task 15: K4/K5 — export_gltf + export_blend via Blender # 2. OrderLines mit result_path + render_status='completed' + output_type
- **Datei**: `backend/app/domains/rendering/tasks.py` SELECT ol.id, ol.result_path, ol.product_id, ol.output_type_id, ot.is_animation
- **Was**: `export_gltf_for_order_line_task` und `export_blend_for_order_line_task` überarbeiten: FROM order_lines ol LEFT JOIN output_types ot ON ot.id = ol.output_type_id
- Statt trimesh: Blender subprocess mit `export_gltf.py` / `export_blend.py` WHERE ol.result_path IS NOT NULL AND ol.render_status = 'completed'
- Asset Library path aus LinkedAssetLibrary (via OutputType) übergeben falls vorhanden ```
- GLB → MinIO `production-exports/{cad_file_id}/{order_line_id}.glb` - De-dup: `SELECT id FROM media_assets WHERE storage_key = ?` vor jedem Insert
- .blend → MinIO `production-exports/{cad_file_id}/{order_line_id}.blend` - CadFile → `MediaAsset(asset_type='thumbnail', cad_file_id=..., storage_key=thumbnail_path, mime_type='image/jpeg')`
- MediaAsset erstellen mit `gltf_production` / `blend_production` type - OrderLine → `MediaAsset(asset_type='turntable' if is_animation else 'still', order_line_id=..., storage_key=result_path)`
- **Akzeptanzkriterium**: Export-Tasks produzieren GLB/BLEND-Dateien in MinIO - 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 ## 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): `<video>` Tag mit `thumbnail_url` als Poster + `download_url` als src — falls download_url MP4 ist
+3
View File
@@ -61,6 +61,9 @@ RUN pip3 install --no-cache-dir -e .
# Install cadquery (heavy — installed after backend deps for better layer caching) # Install cadquery (heavy — installed after backend deps for better layer caching)
RUN pip3 install --no-cache-dir "cadquery>=2.4.0" 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 scripts
COPY render-worker/scripts/ /render-scripts/ COPY render-worker/scripts/ /render-scripts/