Files
HartOMat/backend/app/api/routers/asset_libraries.py
T

245 lines
8.0 KiB
Python

"""Asset Libraries API — CRUD + .blend upload + catalog refresh."""
import uuid
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.database import get_db
from sqlalchemy import text
from app.config import settings
from app.domains.materials.models import AssetLibrary, Material, MaterialAlias
from app.utils.auth import require_admin_or_pm
router = APIRouter(prefix="/asset-libraries", tags=["asset-libraries"])
ASSET_LIB_DIR = "asset-libraries"
def _asset_lib_dir() -> Path:
d = Path(settings.upload_dir) / ASSET_LIB_DIR
d.mkdir(parents=True, exist_ok=True)
return d
# ── Schemas ──────────────────────────────────────────────────────────────────
class AssetLibraryOut(BaseModel):
id: str
name: str
description: str | None
original_filename: str | None
catalog: dict
is_active: bool
created_at: str
updated_at: str
class AssetLibraryUpdate(BaseModel):
name: str | None = None
description: str | None = None
is_active: bool | None = None
def _to_out(lib: AssetLibrary) -> dict:
return {
"id": str(lib.id),
"name": lib.name,
"description": lib.description,
"original_filename": lib.original_filename,
"catalog": lib.catalog or {"materials": [], "node_groups": []},
"is_active": lib.is_active,
"created_at": lib.created_at.isoformat(),
"updated_at": lib.updated_at.isoformat(),
}
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/pbr-map")
async def get_material_pbr_map(db: AsyncSession = Depends(get_db)):
"""PBR properties for all materials in the active asset library.
Public (no auth) — needed by all 3D viewers.
Returns keyed by canonical name AND all known aliases so the viewer can
look up materials by raw Excel names (e.g. "Steel--Stahl") without needing
a separate alias resolution step.
"""
result = await db.execute(
select(AssetLibrary).where(AssetLibrary.is_active == True).limit(1) # noqa: E712
)
lib = result.scalar_one_or_none()
if not lib or not lib.catalog:
return JSONResponse(content={}, headers={"Cache-Control": "public, max-age=3600"})
materials = lib.catalog.get("materials", [])
pbr_map: dict = {}
for m in materials:
if isinstance(m, str):
continue # old format — skip
pbr_map[m["name"]] = {
"base_color": m.get("base_color", [0.5, 0.5, 0.5]),
"metallic": m.get("metallic", 0.0),
"roughness": m.get("roughness", 0.5),
"transmission": m.get("transmission", 0.0),
"ior": m.get("ior", 1.45),
}
# Also index by aliases so frontend can look up by raw Excel names
# (e.g. "Steel--Stahl" → same PBR as "HARTOMAT_010101_Steel-Bare")
# Bypass RLS — this is public data and aliases may have NULL tenant_id
if pbr_map:
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
alias_result = await db.execute(
select(MaterialAlias.alias, Material.name)
.join(Material, MaterialAlias.material_id == Material.id)
)
for alias, canonical_name in alias_result.all():
if canonical_name in pbr_map and alias not in pbr_map:
pbr_map[alias] = pbr_map[canonical_name]
return JSONResponse(content=pbr_map, headers={"Cache-Control": "public, max-age=3600"})
@router.get("", response_model=list[AssetLibraryOut])
async def list_asset_libraries(
db: AsyncSession = Depends(get_db),
_user=Depends(require_admin_or_pm),
):
result = await db.execute(select(AssetLibrary).order_by(AssetLibrary.name))
return [_to_out(lib) for lib in result.scalars().all()]
@router.post("", response_model=AssetLibraryOut, status_code=status.HTTP_201_CREATED)
async def create_asset_library(
name: str = Form(...),
description: str | None = Form(None),
blend_file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_user=Depends(require_admin_or_pm),
):
lib = AssetLibrary(
name=name,
description=description,
original_filename=blend_file.filename,
catalog={"materials": [], "node_groups": []},
)
db.add(lib)
await db.flush() # get the id
# Save .blend file
dest = _asset_lib_dir() / f"{lib.id}.blend"
with dest.open("wb") as f:
shutil.copyfileobj(blend_file.file, f)
lib.blend_file_path = str(dest)
await db.commit()
await db.refresh(lib)
# Queue catalog refresh
try:
from app.domains.materials.tasks import refresh_asset_library_catalog
refresh_asset_library_catalog.delay(str(lib.id))
except Exception:
pass # task queuing failure is non-blocking
return _to_out(lib)
@router.get("/{lib_id}", response_model=AssetLibraryOut)
async def get_asset_library(
lib_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_user=Depends(require_admin_or_pm),
):
lib = await db.get(AssetLibrary, lib_id)
if not lib:
raise HTTPException(status_code=404, detail="Asset library not found")
return _to_out(lib)
@router.patch("/{lib_id}", response_model=AssetLibraryOut)
async def update_asset_library(
lib_id: uuid.UUID,
body: AssetLibraryUpdate,
db: AsyncSession = Depends(get_db),
_user=Depends(require_admin_or_pm),
):
lib = await db.get(AssetLibrary, lib_id)
if not lib:
raise HTTPException(status_code=404, detail="Asset library not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(lib, field, value)
await db.commit()
await db.refresh(lib)
return _to_out(lib)
@router.post("/{lib_id}/upload-blend", response_model=AssetLibraryOut)
async def upload_blend(
lib_id: uuid.UUID,
blend_file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_user=Depends(require_admin_or_pm),
):
lib = await db.get(AssetLibrary, lib_id)
if not lib:
raise HTTPException(status_code=404, detail="Asset library not found")
dest = _asset_lib_dir() / f"{lib.id}.blend"
with dest.open("wb") as f:
shutil.copyfileobj(blend_file.file, f)
lib.blend_file_path = str(dest)
lib.original_filename = blend_file.filename
await db.commit()
await db.refresh(lib)
try:
from app.domains.materials.tasks import refresh_asset_library_catalog
refresh_asset_library_catalog.delay(str(lib.id))
except Exception:
pass
return _to_out(lib)
@router.post("/{lib_id}/refresh-catalog", response_model=AssetLibraryOut)
async def refresh_catalog(
lib_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_user=Depends(require_admin_or_pm),
):
lib = await db.get(AssetLibrary, lib_id)
if not lib:
raise HTTPException(status_code=404, detail="Asset library not found")
if not lib.blend_file_path:
raise HTTPException(status_code=400, detail="No .blend file uploaded yet")
from app.domains.materials.tasks import refresh_asset_library_catalog
refresh_asset_library_catalog.delay(str(lib.id))
return _to_out(lib)
@router.delete("/{lib_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_asset_library(
lib_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_user=Depends(require_admin_or_pm),
):
lib = await db.get(AssetLibrary, lib_id)
if not lib:
raise HTTPException(status_code=404, detail="Asset library not found")
# Remove .blend file from disk
if lib.blend_file_path:
p = Path(lib.blend_file_path)
if p.exists():
p.unlink()
await db.delete(lib)
await db.commit()