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:
@@ -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 = ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user