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
+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 |
|-------|----------|
| `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 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:
### 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