"""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 output_type_name: str | None 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_id: str | None = None 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 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, "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.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 = File(...), category_key: str | None = Form(None), output_type_id: 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), ): 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 template_id = uuid.uuid4() blend_path = _blend_dir() / f"{template_id}.blend" with open(blend_path, "wb") as f: shutil.copyfileobj(file.file, f) ot_uuid = uuid.UUID(output_type_id) if output_type_id else None 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, 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.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.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 "output_type_id" in updates: val = updates["output_type_id"] if val in ("", "null", None): updates["output_type_id"] = None else: updates["output_type_id"] = uuid.UUID(val) if updates: updates["updated_at"] = datetime.utcnow() await db.execute( sql_update(RenderTemplate) .where(RenderTemplate.id == template_id) .values(**updates) ) 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.scalar_one_or_none() if not tmpl: raise HTTPException(404, detail="Render template not found") # Delete .blend file blend_path = Path(tmpl.blend_file_path) if 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.scalar_one_or_none() if not tmpl: raise HTTPException(404, detail="Render template not found") blend_path = _blend_dir() / f"{template_id}.blend" # Remove old file if path changed old_path = Path(tmpl.blend_file_path) if old_path.exists() and old_path != blend_path: 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.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()