b583b0d7a2
- 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>
441 lines
16 KiB
Python
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()
|