Files
HartOMat/backend/app/api/routers/render_templates.py
T
Hartmut b583b0d7a2 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>
2026-03-14 12:16:37 +01:00

441 lines
16 KiB
Python

"""Render Templates API — CRUD + .blend file upload/download + material library."""
import uuid
import shutil
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update as sql_update, delete as sql_delete
from pydantic import BaseModel
from app.database import get_db
from app.config import settings as app_settings
from app.models.user import User
from app.models.render_template import RenderTemplate
from app.models.output_type import OutputType
from app.models.system_setting import SystemSetting
from app.utils.auth import require_admin_or_pm
router = APIRouter(tags=["render-templates"])
BLEND_DIR = "blend-templates"
def _blend_dir() -> Path:
d = Path(app_settings.upload_dir) / BLEND_DIR
d.mkdir(parents=True, exist_ok=True)
return d
# ── Schemas ──────────────────────────────────────────────────────────────────
class RenderTemplateOut(BaseModel):
id: str
name: str
category_key: str | None
output_type_id: str | None # legacy single FK
output_type_name: str | None # legacy
output_type_ids: list[str] # M2M
output_type_names: list[str] # M2M display names
blend_file_path: str
original_filename: str
target_collection: str
material_replace_enabled: bool
lighting_only: bool
shadow_catcher_enabled: bool
camera_orbit: bool
is_active: bool
created_at: str
updated_at: str
model_config = {"from_attributes": True}
class RenderTemplateUpdate(BaseModel):
name: str | None = None
category_key: str | None = None
output_type_ids: list[str] | None = None # replaces output_type_id
target_collection: str | None = None
material_replace_enabled: bool | None = None
lighting_only: bool | None = None
shadow_catcher_enabled: bool | None = None
camera_orbit: bool | None = None
is_active: bool | None = None
class MaterialLibraryInfo(BaseModel):
exists: bool
filename: str | None = None
size_bytes: int | None = None
path: str | None = None
def _to_out(t: RenderTemplate) -> dict:
ot_name = None
if t.output_type:
ot_name = t.output_type.name
# M2M output types
ot_ids = [str(ot.id) for ot in t.output_types] if t.output_types else []
ot_names = [ot.name for ot in t.output_types] if t.output_types else []
return {
"id": str(t.id),
"name": t.name,
"category_key": t.category_key,
"output_type_id": str(t.output_type_id) if t.output_type_id else None,
"output_type_name": ot_name,
"output_type_ids": ot_ids,
"output_type_names": ot_names,
"blend_file_path": t.blend_file_path,
"original_filename": t.original_filename,
"target_collection": t.target_collection,
"material_replace_enabled": t.material_replace_enabled,
"lighting_only": t.lighting_only,
"shadow_catcher_enabled": t.shadow_catcher_enabled,
"camera_orbit": t.camera_orbit,
"is_active": t.is_active,
"created_at": t.created_at.isoformat() if t.created_at else "",
"updated_at": t.updated_at.isoformat() if t.updated_at else "",
}
# ── CRUD Endpoints ───────────────────────────────────────────────────────────
@router.get("/render-templates", response_model=list[RenderTemplateOut])
async def list_render_templates(
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RenderTemplate).order_by(RenderTemplate.created_at.desc())
)
return [_to_out(t) for t in result.unique().scalars().all()]
@router.post("/render-templates", response_model=RenderTemplateOut, status_code=status.HTTP_201_CREATED)
async def create_render_template(
name: str = Form(...),
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),
shadow_catcher_enabled: bool = Form(False),
camera_orbit: bool = Form(True),
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
# 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"
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=final_blend_path,
original_filename=original_filename,
target_collection=target_collection,
material_replace_enabled=material_replace_enabled,
lighting_only=lighting_only,
shadow_catcher_enabled=shadow_catcher_enabled,
camera_orbit=camera_orbit,
)
db.add(tmpl)
await db.flush()
# 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=uuid.UUID(ot_id_str),
)
)
await db.commit()
await db.refresh(tmpl)
# Eagerly load output_type for response
if ot_uuid:
ot = await db.get(OutputType, ot_uuid)
tmpl.output_type = ot
return _to_out(tmpl)
@router.patch("/render-templates/{template_id}", response_model=RenderTemplateOut)
async def update_render_template(
template_id: uuid.UUID,
body: RenderTemplateUpdate,
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
tmpl = result.unique().scalar_one_or_none()
if not tmpl:
raise HTTPException(404, detail="Render template not found")
updates = body.model_dump(exclude_unset=True)
# Normalise empty strings to None for nullable fields
if "category_key" in updates and updates["category_key"] in ("", "null"):
updates["category_key"] = None
# Handle M2M output_type_ids
new_ot_ids: list[str] | None = updates.pop("output_type_ids", None)
if updates:
updates["updated_at"] = datetime.utcnow()
await db.execute(
sql_update(RenderTemplate)
.where(RenderTemplate.id == template_id)
.values(**updates)
)
# Sync M2M relationship
if new_ot_ids is not None:
from app.domains.rendering.models import render_template_output_types
# Delete existing links
await db.execute(
sql_delete(render_template_output_types).where(
render_template_output_types.c.template_id == template_id
)
)
# Insert new links
for ot_id in new_ot_ids:
await db.execute(
render_template_output_types.insert().values(
template_id=template_id,
output_type_id=uuid.UUID(ot_id),
)
)
# Also update legacy FK to first OT (for backward compat)
legacy_ot = uuid.UUID(new_ot_ids[0]) if new_ot_ids else None
await db.execute(
sql_update(RenderTemplate)
.where(RenderTemplate.id == template_id)
.values(output_type_id=legacy_ot, updated_at=datetime.utcnow())
)
await db.commit()
await db.refresh(tmpl)
return _to_out(tmpl)
@router.delete("/render-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_render_template(
template_id: uuid.UUID,
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
tmpl = result.unique().scalar_one_or_none()
if not tmpl:
raise HTTPException(404, detail="Render template not found")
# Only delete .blend file if no other template shares it
blend_path = Path(tmpl.blend_file_path)
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))
await db.commit()
@router.post("/render-templates/{template_id}/upload", response_model=RenderTemplateOut)
async def upload_blend_file(
template_id: uuid.UUID,
file: UploadFile = File(...),
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
"""Re-upload a .blend file for an existing template."""
if not file.filename or not file.filename.endswith(".blend"):
raise HTTPException(400, detail="File must be a .blend file")
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
tmpl = result.unique().scalar_one_or_none()
if not tmpl:
raise HTTPException(404, detail="Render template not found")
blend_path = _blend_dir() / f"{template_id}.blend"
# 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:
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)
await db.execute(
sql_update(RenderTemplate)
.where(RenderTemplate.id == template_id)
.values(
blend_file_path=str(blend_path),
original_filename=file.filename,
updated_at=datetime.utcnow(),
)
)
await db.commit()
await db.refresh(tmpl)
return _to_out(tmpl)
@router.get("/render-templates/{template_id}/download")
async def download_blend_file(
template_id: uuid.UUID,
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
tmpl = result.unique().scalar_one_or_none()
if not tmpl:
raise HTTPException(404, detail="Render template not found")
blend_path = Path(tmpl.blend_file_path)
if not blend_path.exists():
raise HTTPException(404, detail=".blend file not found on disk")
return FileResponse(
path=str(blend_path),
filename=tmpl.original_filename,
media_type="application/octet-stream",
)
# ── Material Library ─────────────────────────────────────────────────────────
MATERIAL_LIBRARY_FILENAME = "material_library.blend"
async def _save_setting(db: AsyncSession, key: str, value: str) -> None:
result = await db.execute(
sql_update(SystemSetting)
.where(SystemSetting.key == key)
.values(value=value, updated_at=datetime.utcnow())
)
if result.rowcount == 0:
db.add(SystemSetting(key=key, value=value, updated_at=datetime.utcnow()))
@router.post("/admin/settings/material-library", response_model=MaterialLibraryInfo)
async def upload_material_library(
file: UploadFile = File(...),
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")
lib_path = _blend_dir() / MATERIAL_LIBRARY_FILENAME
with open(lib_path, "wb") as f:
shutil.copyfileobj(file.file, f)
await _save_setting(db, "material_library_path", str(lib_path))
await db.commit()
return MaterialLibraryInfo(
exists=True,
filename=file.filename,
size_bytes=lib_path.stat().st_size,
path=str(lib_path),
)
@router.get("/admin/settings/material-library", response_model=MaterialLibraryInfo)
async def get_material_library(
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == "material_library_path")
)
row = result.scalar_one_or_none()
path_str = row.value if row else ""
if path_str and Path(path_str).exists():
p = Path(path_str)
return MaterialLibraryInfo(
exists=True,
filename=p.name,
size_bytes=p.stat().st_size,
path=path_str,
)
return MaterialLibraryInfo(exists=False)
@router.delete("/admin/settings/material-library", status_code=status.HTTP_204_NO_CONTENT)
async def delete_material_library(
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == "material_library_path")
)
row = result.scalar_one_or_none()
if row and row.value:
p = Path(row.value)
if p.exists():
p.unlink(missing_ok=True)
await _save_setting(db, "material_library_path", "")
await db.commit()