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
+63 -2
View File
@@ -270,12 +270,73 @@ async def delete_product(
raise HTTPException(404, detail="Product not found")
if hard:
from sqlalchemy import delete as sql_delete
# Delete order_lines referencing this product
from app.domains.media.models import MediaAsset
from app.core.storage import get_storage
# 1. Collect storage keys from MediaAssets before cascade deletes them
media_result = await db.execute(
select(MediaAsset.storage_key).where(MediaAsset.product_id == product_id)
)
storage_keys = [row[0] for row in media_result.all() if row[0]]
# 2. Collect render result paths from order lines
ol_result = await db.execute(
select(OrderLine.result_path).where(
OrderLine.product_id == product_id,
OrderLine.result_path.isnot(None),
)
)
result_paths = [row[0] for row in ol_result.all() if row[0]]
# 3. Check if CadFile is used by other products
cad_file_id = product.cad_file_id
orphan_cad = False
if cad_file_id:
other_count = await db.execute(
select(func.count(Product.id)).where(
Product.cad_file_id == cad_file_id,
Product.id != product_id,
)
)
orphan_cad = (other_count.scalar() or 0) == 0
# 4. Delete order_lines referencing this product
await db.execute(sql_delete(OrderLine).where(OrderLine.product_id == product_id))
# 5. Delete orphaned CadFile if no other products reference it
if orphan_cad and cad_file_id:
from app.models.cad_file import CadFile
# Collect CadFile media assets too
cad_media_result = await db.execute(
select(MediaAsset.storage_key).where(MediaAsset.cad_file_id == cad_file_id)
)
storage_keys.extend(row[0] for row in cad_media_result.all() if row[0])
product.cad_file_id = None
await db.flush()
await db.execute(sql_delete(CadFile).where(CadFile.id == cad_file_id))
# 6. Delete product (cascades MediaAsset + ProductRenderPosition)
await db.delete(product)
await db.commit()
# 7. Clean up storage files (best-effort, after commit)
storage = get_storage()
for key in storage_keys:
try:
storage.delete(key)
except Exception:
pass
# Clean up render result files on disk
import os
for path in result_paths:
try:
if os.path.isfile(path):
os.unlink(path)
except Exception:
pass
else:
product.is_active = False
await db.commit()
await db.commit()
@router.post("/{product_id}/cad", status_code=status.HTTP_201_CREATED)