feat: initial commit
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user