feat: per-position camera settings, material alias dialog, product delete, media browser links

- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 12:16:37 +01:00
parent 0020376702
commit b583b0d7a2
48 changed files with 1827 additions and 376 deletions
+57
View File
@@ -825,3 +825,60 @@ async def import_existing_media_assets(
await db.commit()
return {"created": created, "skipped": skipped}
@router.delete("/settings/purge-render-media", status_code=status.HTTP_200_OK)
async def purge_render_media(
admin: User = Depends(require_global_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete all still and turntable MediaAsset records and their backing files.
This removes rendered images and animations but leaves thumbnails, GLBs,
STLs, and USD masters intact.
"""
import logging
from pathlib import Path
from app.config import settings
from app.core.storage import get_storage
from app.domains.media.models import MediaAsset, MediaAssetType
logger = logging.getLogger(__name__)
storage = get_storage()
result = await db.execute(
select(MediaAsset).where(
MediaAsset.asset_type.in_([MediaAssetType.still, MediaAssetType.turntable])
)
)
assets = result.scalars().all()
deleted_db = 0
deleted_files = 0
freed_bytes = 0
for asset in assets:
# Delete backing file
key = asset.storage_key
try:
candidate = Path(key) if Path(key).is_absolute() else Path(settings.upload_dir) / key
if candidate.exists():
freed_bytes += candidate.stat().st_size
candidate.unlink()
deleted_files += 1
elif hasattr(storage, 'delete'):
storage.delete(key)
deleted_files += 1
except Exception as exc:
logger.warning("Could not delete file for asset %s (%s): %s", asset.id, key, exc)
await db.delete(asset)
deleted_db += 1
await db.commit()
return {
"deleted_records": deleted_db,
"deleted_files": deleted_files,
"freed_mb": round(freed_bytes / 1024 / 1024, 1),
"message": f"Purged {deleted_db} still/turntable asset(s), freed {round(freed_bytes / 1024 / 1024, 1)} MB",
}