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:
@@ -116,9 +116,11 @@ async def list_render_templates(
|
||||
@router.post("/render-templates", response_model=RenderTemplateOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_render_template(
|
||||
name: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
file: UploadFile | None = File(None),
|
||||
clone_blend_from: str | None = Form(None),
|
||||
category_key: str | None = Form(None),
|
||||
output_type_id: str | None = Form(None),
|
||||
output_type_ids: str | None = Form(None),
|
||||
target_collection: str = Form("Product"),
|
||||
material_replace_enabled: bool = Form(False),
|
||||
lighting_only: bool = Form(False),
|
||||
@@ -127,30 +129,54 @@ async def create_render_template(
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not file.filename or not file.filename.endswith(".blend"):
|
||||
raise HTTPException(400, detail="File must be a .blend file")
|
||||
|
||||
# Normalise empty strings from form data to None
|
||||
if category_key == "" or category_key == "null":
|
||||
category_key = None
|
||||
if output_type_id == "" or output_type_id == "null":
|
||||
output_type_id = None
|
||||
if clone_blend_from == "" or clone_blend_from == "null":
|
||||
clone_blend_from = None
|
||||
|
||||
template_id = uuid.uuid4()
|
||||
blend_path = _blend_dir() / f"{template_id}.blend"
|
||||
|
||||
with open(blend_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
if file and file.filename:
|
||||
if not file.filename.endswith(".blend"):
|
||||
raise HTTPException(400, detail="File must be a .blend file")
|
||||
with open(blend_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
original_filename = file.filename
|
||||
final_blend_path = str(blend_path)
|
||||
elif clone_blend_from:
|
||||
# Share the same .blend file (no copy — just reference the same path)
|
||||
source = await db.execute(
|
||||
select(RenderTemplate).where(RenderTemplate.id == uuid.UUID(clone_blend_from))
|
||||
)
|
||||
source_tmpl = source.unique().scalar_one_or_none()
|
||||
if not source_tmpl:
|
||||
raise HTTPException(404, detail="Source template not found")
|
||||
source_path = Path(source_tmpl.blend_file_path)
|
||||
if not source_path.exists():
|
||||
raise HTTPException(404, detail="Source .blend file not found on disk")
|
||||
final_blend_path = source_tmpl.blend_file_path
|
||||
original_filename = source_tmpl.original_filename
|
||||
else:
|
||||
raise HTTPException(400, detail="Provide either a .blend file or clone_blend_from template ID")
|
||||
|
||||
ot_uuid = uuid.UUID(output_type_id) if output_type_id else None
|
||||
|
||||
# Parse M2M output_type_ids (comma-separated string from FormData)
|
||||
m2m_ot_ids: list[str] = []
|
||||
if output_type_ids and output_type_ids.strip():
|
||||
m2m_ot_ids = [s.strip() for s in output_type_ids.split(",") if s.strip()]
|
||||
|
||||
tmpl = RenderTemplate(
|
||||
id=template_id,
|
||||
name=name,
|
||||
category_key=category_key,
|
||||
output_type_id=ot_uuid,
|
||||
blend_file_path=str(blend_path),
|
||||
original_filename=file.filename,
|
||||
blend_file_path=final_blend_path,
|
||||
original_filename=original_filename,
|
||||
target_collection=target_collection,
|
||||
material_replace_enabled=material_replace_enabled,
|
||||
lighting_only=lighting_only,
|
||||
@@ -160,12 +186,13 @@ async def create_render_template(
|
||||
db.add(tmpl)
|
||||
await db.flush()
|
||||
|
||||
# Sync M2M from initial output_type_id
|
||||
if ot_uuid:
|
||||
from app.domains.rendering.models import render_template_output_types
|
||||
# Sync M2M output types
|
||||
from app.domains.rendering.models import render_template_output_types
|
||||
ot_ids_to_link = m2m_ot_ids if m2m_ot_ids else ([str(ot_uuid)] if ot_uuid else [])
|
||||
for ot_id_str in ot_ids_to_link:
|
||||
await db.execute(
|
||||
render_template_output_types.insert().values(
|
||||
template_id=template_id, output_type_id=ot_uuid,
|
||||
template_id=template_id, output_type_id=uuid.UUID(ot_id_str),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -250,9 +277,15 @@ async def delete_render_template(
|
||||
if not tmpl:
|
||||
raise HTTPException(404, detail="Render template not found")
|
||||
|
||||
# Delete .blend file
|
||||
# Only delete .blend file if no other template shares it
|
||||
blend_path = Path(tmpl.blend_file_path)
|
||||
if blend_path.exists():
|
||||
other_refs = await db.execute(
|
||||
select(RenderTemplate.id).where(
|
||||
RenderTemplate.blend_file_path == tmpl.blend_file_path,
|
||||
RenderTemplate.id != template_id,
|
||||
)
|
||||
)
|
||||
if not other_refs.first() and blend_path.exists():
|
||||
blend_path.unlink(missing_ok=True)
|
||||
|
||||
await db.execute(sql_delete(RenderTemplate).where(RenderTemplate.id == template_id))
|
||||
@@ -277,10 +310,17 @@ async def upload_blend_file(
|
||||
|
||||
blend_path = _blend_dir() / f"{template_id}.blend"
|
||||
|
||||
# Remove old file if path changed
|
||||
# Only remove old file if no other template shares it
|
||||
old_path = Path(tmpl.blend_file_path)
|
||||
if old_path.exists() and old_path != blend_path:
|
||||
old_path.unlink(missing_ok=True)
|
||||
other_refs = await db.execute(
|
||||
select(RenderTemplate.id).where(
|
||||
RenderTemplate.blend_file_path == tmpl.blend_file_path,
|
||||
RenderTemplate.id != template_id,
|
||||
)
|
||||
)
|
||||
if not other_refs.first():
|
||||
old_path.unlink(missing_ok=True)
|
||||
|
||||
with open(blend_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
|
||||
Reference in New Issue
Block a user