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:
@@ -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
|
||||
**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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -60,6 +60,12 @@ export async function deleteInvoice(id: string): Promise<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<MediaAsset[]>
|
||||
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<MediaAsset> =>
|
||||
|
||||
@@ -218,8 +218,6 @@ export default function ThreeDViewer({
|
||||
productionGltfUrl,
|
||||
downloadUrls,
|
||||
}: ThreeDViewerProps) {
|
||||
const defaultUrl = `/api/cad/${cadFileId}/model`
|
||||
|
||||
const [mode, setMode] = useState<ViewMode>('geometry')
|
||||
const [wireframe, setWireframe] = useState(false)
|
||||
const [envPreset, setEnvPreset] = useState<EnvPreset>('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({
|
||||
<directionalLight position={[5, 10, 7]} intensity={1.0} castShadow />
|
||||
<directionalLight position={[-5, -5, -5]} intensity={0.25} />
|
||||
|
||||
<GltfErrorBoundary onError={handleError}>
|
||||
<Suspense fallback={null}>
|
||||
<ModelWithReady
|
||||
key={activeUrl}
|
||||
url={activeUrl}
|
||||
wireframe={wireframe}
|
||||
onReady={handleModelReady}
|
||||
/>
|
||||
</Suspense>
|
||||
</GltfErrorBoundary>
|
||||
{activeUrl && (
|
||||
<GltfErrorBoundary onError={handleError}>
|
||||
<Suspense fallback={null}>
|
||||
<ModelWithReady
|
||||
key={activeUrl}
|
||||
url={activeUrl}
|
||||
wireframe={wireframe}
|
||||
onReady={handleModelReady}
|
||||
/>
|
||||
</Suspense>
|
||||
</GltfErrorBoundary>
|
||||
)}
|
||||
|
||||
<OrbitControls enablePan enableZoom enableRotate minDistance={0.3} maxDistance={100} />
|
||||
<Environment preset={envPreset} />
|
||||
|
||||
@@ -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 (
|
||||
<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 */}
|
||||
<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="flex items-center gap-3">
|
||||
<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="text-xs text-content-muted">Automat</p>
|
||||
</div>
|
||||
<NotificationCenter />
|
||||
{/* NotificationCenter in sidebar header (desktop); hidden on mobile (shown in top bar) */}
|
||||
<span className="hidden md:block">
|
||||
<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>
|
||||
|
||||
<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 */}
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<Plus size={18} />
|
||||
@@ -79,6 +121,7 @@ export default function Layout() {
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'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') && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'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') && (
|
||||
<NavLink
|
||||
to="/billing"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'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') && (
|
||||
<NavLink
|
||||
to="/media"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'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') && (
|
||||
<NavLink
|
||||
to="/workers"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'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') && (
|
||||
<NavLink
|
||||
to="/workflows"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'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') && (
|
||||
<NavLink
|
||||
to="/asset-libraries"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'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' && (
|
||||
<NavLink
|
||||
to="/notification-settings"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'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' && (
|
||||
<NavLink
|
||||
to="/tenants"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'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>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<main className="flex-1 overflow-auto min-w-0 pt-12 md:pt-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
</button>
|
||||
<p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
|
||||
</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 & CAD thumbnails in the Media Browser.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => generateMissingStlsMut.mutate()}
|
||||
|
||||
@@ -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() {
|
||||
<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 flex items-center gap-1">
|
||||
<a
|
||||
href={getInvoicePdfUrl(inv.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Download size={15} />
|
||||
</a>
|
||||
</button>
|
||||
{inv.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -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 (
|
||||
<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 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 (
|
||||
<ThreeDViewer
|
||||
cadFileId={id}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
LayoutGrid, LayoutList, Download, Archive, Image, Film, Box, FileCode2, Layers,
|
||||
ChevronLeft, ChevronRight, Search,
|
||||
ChevronLeft, ChevronRight, Search, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -32,11 +32,10 @@ const TYPE_COLORS: Record<MediaAssetType, string> = {
|
||||
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<MediaAssetType> = 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 ? (
|
||||
<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">
|
||||
<TypeIcon type={asset.asset_type} />
|
||||
@@ -169,13 +184,23 @@ export default function MediaBrowserPage() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
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 [page, setPage] = useState(0)
|
||||
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 = {
|
||||
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() {
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative">
|
||||
<Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by product ID..."
|
||||
value={productIdInput}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={assetType}
|
||||
onChange={e => { 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"
|
||||
>
|
||||
<option value="">All types</option>
|
||||
{ALL_TYPES.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<div className="relative">
|
||||
<Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by product ID..."
|
||||
value={productIdInput}
|
||||
onChange={e => { 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"
|
||||
/>
|
||||
</div>
|
||||
{/* Primary type chips */}
|
||||
{PRIMARY_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>
|
||||
))}
|
||||
</select>
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-sm text-content-muted">{selectedIds.size} selected</span>
|
||||
<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 && (
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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<AssetLibrary[]>
|
||||
export async function uploadAssetLibrary(name: string, file: File, description?: string): Promise<AssetLibrary>
|
||||
export async function refreshLibraryCatalog(id: string): Promise<AssetLibrary>
|
||||
export async function deleteAssetLibrary(id: string): Promise<void>
|
||||
export async function updateAssetLibrary(id: string, data: Partial<AssetLibrary>): Promise<AssetLibrary>
|
||||
```
|
||||
- **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` → `<AssetLibraryPage />` (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<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)
|
||||
- **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): `<video>` Tag mit `thumbnail_url` als Poster + `download_url` als src — falls download_url MP4 ist
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user