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
+96
View File
@@ -865,6 +865,50 @@ async def add_order_line(
return _build_line_out(line_loaded)
@router.get("/{order_id}/check-materials")
async def check_materials(
order_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Check if all materials in this order's products are mapped to library materials."""
from app.domains.materials.service import find_unmapped_materials
result = await db.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
raise HTTPException(404, detail="Order not found")
lines_result = await db.execute(
select(OrderLine)
.options(selectinload(OrderLine.product))
.where(OrderLine.order_id == order_id)
)
lines = lines_result.scalars().all()
# Collect all unique material names from all products
all_material_names: list[str] = []
seen: set[str] = set()
for line in lines:
if not line.product or not line.product.cad_part_materials:
continue
for entry in line.product.cad_part_materials:
mat_name = entry.get("material", "")
if mat_name and mat_name.lower() not in seen:
seen.add(mat_name.lower())
all_material_names.append(mat_name)
unmapped = await find_unmapped_materials(all_material_names, db)
total = len(all_material_names)
mapped = total - len(unmapped)
return {
"unmapped": unmapped,
"total_materials": total,
"mapped_count": mapped,
}
@router.post("/{order_id}/dispatch-renders")
async def dispatch_renders(
order_id: uuid.UUID,
@@ -1000,6 +1044,58 @@ async def cancel_line_render(
}
@router.post("/{order_id}/lines/{line_id}/dispatch-render")
async def dispatch_single_line_render(
order_id: uuid.UUID,
line_id: uuid.UUID,
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
"""Dispatch (or retry) a render for a single order line (admin/PM only)."""
result = await db.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
raise HTTPException(404, detail="Order not found")
line_result = await db.execute(
select(OrderLine).where(OrderLine.id == line_id, OrderLine.order_id == order.id)
)
line = line_result.scalar_one_or_none()
if not line:
raise HTTPException(404, detail="Order line not found")
if line.render_status not in ("pending", "failed", "cancelled"):
raise HTTPException(400, detail=f"Cannot dispatch line in {line.render_status} status")
# Reset to pending
from sqlalchemy import update as sql_update
await db.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(render_status="pending", render_completed_at=None, render_log=None)
)
# Auto-advance order to processing if needed
if order.status in (OrderStatus.submitted, OrderStatus.completed):
now = datetime.utcnow()
order.status = OrderStatus.processing
order.processing_started_at = now
order.completed_at = None
order.updated_at = now
await db.commit()
from app.domains.rendering.dispatch_service import dispatch_render_with_workflow
try:
dispatch_render_with_workflow(str(line.id))
except Exception as exc:
logger.warning("dispatch_render_with_workflow failed for %s: %s", line.id, exc)
from app.tasks.step_tasks import dispatch_order_line_render
dispatch_order_line_render.delay(str(line.id))
return {"dispatched": True, "line_id": str(line.id)}
class RejectLineBody(BaseModel):
reason: str = ""