"""Render Templates API — CRUD + .blend file upload/download + material library.""" import json import uuid import shutil from datetime import datetime from pathlib import Path from typing import Any 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, TypeAdapter, ValidationError from app.database import get_db from app.config import settings as app_settings from app.domains.rendering.workflow_node_registry import WorkflowNodeFieldDefinition 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 workflow_input_schema: list[WorkflowNodeFieldDefinition] 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 workflow_input_schema: list[WorkflowNodeFieldDefinition] | 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 _workflow_input_schema_adapter = TypeAdapter(list[WorkflowNodeFieldDefinition]) def _normalize_workflow_input_schema(schema: Any) -> list[dict[str, Any]]: if schema in (None, "", "null"): return [] try: validated = _workflow_input_schema_adapter.validate_python(schema) except ValidationError as exc: raise HTTPException(status_code=422, detail={"workflow_input_schema": exc.errors()}) from exc return [field.model_dump(mode="json") for field in validated] def _parse_form_workflow_input_schema(raw_schema: str | None) -> list[dict[str, Any]]: if raw_schema in (None, "", "null"): return [] try: payload = json.loads(raw_schema) except json.JSONDecodeError as exc: raise HTTPException(status_code=422, detail="workflow_input_schema must be valid JSON") from exc return _normalize_workflow_input_schema(payload) 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, "workflow_input_schema": t.workflow_input_schema or [], "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), workflow_input_schema: str | None = Form(None), 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, workflow_input_schema=_parse_form_workflow_input_schema(workflow_input_schema), ) 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 if "workflow_input_schema" in updates: updates["workflow_input_schema"] = _normalize_workflow_input_schema(updates["workflow_input_schema"]) # 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()