"""Spoolman inventory proxy endpoints. Translates between Spoolman's data model and Bambuddy's internal InventorySpool format so the frontend can use a single unified inventory UI regardless of whether data comes from the local database or Spoolman. """ from __future__ import annotations import logging import re import time from contextlib import asynccontextmanager from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, field_validator, model_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.api.routes._spoolman_helpers import ( _map_spoolman_spool, _safe_float, _safe_int, assert_safe_spoolman_url, ) from backend.app.core.auth import RequirePermissionIfAuthEnabled from backend.app.core.database import get_db from backend.app.core.permissions import Permission from backend.app.models.settings import Settings from backend.app.models.user import User from backend.app.services.spoolman import ( SpoolmanClient, SpoolmanClientError, SpoolmanNotFoundError, SpoolmanUnavailableError, get_spoolman_client, init_spoolman_client, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/spoolman/inventory", tags=["spoolman-inventory"]) # Cache the last successful health-check timestamp to avoid a round-trip on # every request. A failed check clears the cache immediately. _health_check_cache: dict[str, float] = {} _HEALTH_CHECK_TTL = 30.0 # seconds def _tag_cleared(val: str | None) -> bool: """Return True when a PATCH field explicitly removes a tag (null or empty string).""" return val is None or val == "" async def _get_client(db: AsyncSession) -> SpoolmanClient: """Return an authenticated Spoolman client or raise an HTTP error.""" result = await db.execute(select(Settings)) settings: dict[str, str] = {s.key: s.value for s in result.scalars().all()} enabled = settings.get("spoolman_enabled", "false").lower() == "true" url = settings.get("spoolman_url", "").strip() if not enabled: raise HTTPException(status_code=400, detail="Spoolman integration is not enabled") if not url: raise HTTPException(status_code=400, detail="Spoolman URL is not configured") # SSRF guard: reject dangerous schemes and bare private/loopback/link-local/multicast IPs. # Raises ValueError with a descriptive message on any violation. try: assert_safe_spoolman_url(url) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc # Re-use the cached client when URL is unchanged; reinitialise on URL change (cache invalidation). client = await get_spoolman_client() if not client or client.base_url != url.rstrip("/"): try: client = await init_spoolman_client(url) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc # Only call health_check() when the cached result has expired. # Evict stale entries when URL changes (only one Spoolman URL is active at a time). if url not in _health_check_cache and _health_check_cache: _health_check_cache.clear() now = time.monotonic() last_ok = _health_check_cache.get(url, 0.0) if now - last_ok > _HEALTH_CHECK_TTL: if not await client.health_check(): _health_check_cache.pop(url, None) raise HTTPException(status_code=503, detail="Spoolman server is not reachable") _health_check_cache[url] = now return client @asynccontextmanager async def _translate_spoolman_errors(): """Translate Spoolman exceptions to HTTP responses for all inventory endpoints. Maps SpoolmanNotFoundError → 404 and SpoolmanUnavailableError → 503. Add new SpoolmanClient exception mappings here rather than in individual handlers. """ try: yield except SpoolmanNotFoundError as exc: raise HTTPException(status_code=404, detail="Spool not found in Spoolman") from exc except SpoolmanClientError as exc: raise HTTPException(status_code=exc.status_code, detail="Spoolman rejected the request") from exc except SpoolmanUnavailableError as exc: raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc async def _apply_price_if_set(client: SpoolmanClient, spool: dict, cost_per_kg: float | None) -> dict: """Patch the spool's price via a follow-up update when cost_per_kg is provided. Bambuddy's SpoolmanClient.create_spool() does not forward price to Spoolman's POST /spool endpoint, so a follow-up PATCH via update_spool_full is needed to set it. On failure, logs an error and returns the original spool (caller gets HTTP 200 without price). """ if cost_per_kg is None: return spool try: async with _translate_spoolman_errors(): return await client.update_spool_full(spool["id"], price=cost_per_kg) except HTTPException: logger.error( "Price update failed for spool %d; spool created without price (cost_per_kg=%s)", spool["id"], cost_per_kg, ) return spool # --------------------------------------------------------------------------- # Request / response schemas # --------------------------------------------------------------------------- _HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$") def _validate_rgba(v: str | None) -> str | None: if v is None: return v clean = v.removeprefix("#") if not _HEX_RE.match(clean): raise ValueError("rgba must be a 6 or 8 character hex string (RRGGBB or RRGGBBAA)") return clean.upper() def _validate_storage_location(v: str | None) -> str | None: if v is not None and any(c in v for c in ("\r", "\n", "\x00")): raise ValueError("storage_location must not contain control characters") return v class SpoolmanInventoryCreate(BaseModel): material: str = Field(..., min_length=1, max_length=64) subtype: str | None = Field(None, max_length=64) brand: str | None = Field(None, max_length=128) color_name: str | None = Field(None, max_length=64) rgba: str | None = Field(None, max_length=8) label_weight: int = Field(1000, ge=1, le=100_000) core_weight: int = Field( 250, ge=0, le=10_000 ) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool) weight_used: float = Field(0.0, ge=0.0, le=100_000.0) note: str | None = Field(None, max_length=1000) cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0) storage_location: str | None = Field(None, max_length=255) @field_validator("rgba") @classmethod def validate_rgba(cls, v: str | None) -> str | None: return _validate_rgba(v) @field_validator("storage_location") @classmethod def validate_storage_location(cls, v: str | None) -> str | None: return _validate_storage_location(v) @model_validator(mode="after") def validate_weight_consistency(self) -> SpoolmanInventoryCreate: if self.weight_used > self.label_weight: raise ValueError("weight_used must not exceed label_weight") return self class SpoolmanInventoryUpdate(BaseModel): material: str | None = Field(None, min_length=1, max_length=64) subtype: str | None = Field(None, max_length=64) brand: str | None = Field(None, max_length=128) color_name: str | None = Field(None, max_length=64) rgba: str | None = Field(None, max_length=8) label_weight: int | None = Field(None, ge=1, le=100_000) core_weight: int | None = Field( None, ge=0, le=10_000 ) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool) weight_used: float | None = Field(None, ge=0.0, le=100_000.0) note: str | None = Field(None, max_length=1000) cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0) tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$") tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$") storage_location: str | None = Field(None, max_length=255) @field_validator("rgba") @classmethod def validate_rgba(cls, v: str | None) -> str | None: return _validate_rgba(v) @field_validator("storage_location") @classmethod def validate_storage_location(cls, v: str | None) -> str | None: return _validate_storage_location(v) @model_validator(mode="after") def validate_weight_consistency(self) -> SpoolmanInventoryUpdate: if self.weight_used is not None and self.label_weight is not None: if self.weight_used > self.label_weight: raise ValueError("weight_used must not exceed label_weight") return self class SpoolmanInventoryBulkCreate(BaseModel): spool: SpoolmanInventoryCreate quantity: int = Field(1, ge=1, le=50) class SpoolWeightUpdate(BaseModel): weight_grams: float = Field(..., ge=0.0, le=100_000.0) # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get("/spools") async def list_spools( include_archived: bool = Query(False), db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), ) -> list[dict]: """Return all Spoolman spools in the InventorySpool format.""" client = await _get_client(db) async with _translate_spoolman_errors(): spools = await client.get_all_spools(allow_archived=include_archived) result = [] for s in spools: try: result.append(_map_spoolman_spool(s)) except ValueError as exc: logger.warning("Skipping malformed Spoolman spool (id=%r): %s", s.get("id"), exc) return result @router.get("/spools/{spool_id}") async def get_spool( spool_id: int = Path(..., gt=0), db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), ) -> dict: """Return a single Spoolman spool in the InventorySpool format.""" client = await _get_client(db) async with _translate_spoolman_errors(): spool = await client.get_spool(spool_id) try: return _map_spoolman_spool(spool) except ValueError as exc: logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc) raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc @router.post("/spools") async def create_spool( data: SpoolmanInventoryCreate, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ) -> dict: """Create a new spool in Spoolman, auto-creating vendor and filament as needed.""" client = await _get_client(db) color_hex = (data.rgba or "808080FF")[:6] async with _translate_spoolman_errors(): filament_id = await client.find_or_create_filament( material=data.material, subtype=data.subtype or "", brand=data.brand, color_hex=color_hex, label_weight=data.label_weight, color_name=data.color_name, ) if not filament_id: raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman") remaining = max(0.0, data.label_weight - data.weight_used) spool = await client.create_spool( filament_id=filament_id, remaining_weight=remaining, comment=data.note or None, location=data.storage_location or None, ) if not spool: raise HTTPException(status_code=500, detail="Failed to create spool in Spoolman") spool = await _apply_price_if_set(client, spool, data.cost_per_kg) return _map_spoolman_spool(spool) @router.post("/spools/bulk") async def bulk_create_spools( payload: SpoolmanInventoryBulkCreate, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ) -> Response: """Create multiple identical spools in Spoolman.""" client = await _get_client(db) data = payload.spool color_hex = (data.rgba or "808080FF")[:6] async with _translate_spoolman_errors(): filament_id = await client.find_or_create_filament( material=data.material, subtype=data.subtype or "", brand=data.brand, color_hex=color_hex, label_weight=data.label_weight, ) if not filament_id: raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman") remaining = max(0.0, data.label_weight - data.weight_used) created: list[dict] = [] for _ in range(payload.quantity): spool = await client.create_spool( filament_id=filament_id, remaining_weight=remaining, comment=data.note or None, location=data.storage_location or None, ) if spool: spool = await _apply_price_if_set(client, spool, data.cost_per_kg) created.append(_map_spoolman_spool(spool)) if not created: raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman") if len(created) < payload.quantity: # Some spool creations failed — return 207 Multi-Status so the caller # can distinguish a full success from a partial one and show a useful message. return JSONResponse( status_code=207, content={ "created": created, "requested_count": payload.quantity, "failed_count": payload.quantity - len(created), }, ) return JSONResponse(status_code=200, content=created) @router.patch("/spools/{spool_id}") async def update_spool( *, spool_id: int = Path(..., gt=0), data: SpoolmanInventoryUpdate, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ) -> dict: """Update an existing Spoolman spool, re-linking the filament if metadata changed.""" client = await _get_client(db) async with _translate_spoolman_errors(): current = await client.get_spool(spool_id) cur_filament: dict = current.get("filament") or {} cur_vendor: dict = cur_filament.get("vendor") or {} cur_mat: str = (cur_filament.get("material") or "").strip() cur_name: str = (cur_filament.get("name") or "").strip() if cur_mat and cur_name.upper().startswith(cur_mat.upper()): cur_subtype: str = cur_name[len(cur_mat) :].strip() else: cur_subtype = cur_name # Resolve final values: use request value if provided, else keep current material = data.material if data.material is not None else cur_mat subtype = data.subtype if data.subtype is not None else cur_subtype brand = data.brand if data.brand is not None else (cur_vendor.get("name") or None) color_name = data.color_name if data.color_name is not None else (cur_filament.get("color_name") or None) cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#") rgba = data.rgba if data.rgba is not None else (cur_color + "FF") label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000) weight_used = data.weight_used if data.weight_used is not None else float(current.get("used_weight") or 0) note = data.note if data.note is not None else current.get("comment") storage_location_changed = "storage_location" in data.model_fields_set storage_location = data.storage_location if storage_location_changed else current.get("location") color_hex = rgba[:6] async with _translate_spoolman_errors(): filament_id = await client.find_or_create_filament( material=material, subtype=subtype or "", brand=brand, color_hex=color_hex, label_weight=label_weight, color_name=color_name, ) if not filament_id: raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman") remaining = max(0.0, label_weight - weight_used) # Tag removal: clear only the "tag" key so other custom Spoolman extra fields # set outside Bambuddy are preserved. tag_nulled = ( ("tag_uid" in data.model_fields_set or "tray_uuid" in data.model_fields_set) and _tag_cleared(data.tag_uid) and _tag_cleared(data.tray_uuid) ) # Serialise tag-clear + PATCH under the per-spool extra lock to prevent a # concurrent merge_spool_extra call (e.g. NFC write-back) from overwriting # the tag key between our read and our write. async with client.extra_lock(spool_id): if tag_nulled: # Re-fetch inside the lock so we work with fresh extra data. async with _translate_spoolman_errors(): fresh = await client.get_spool(spool_id) cur_extra = dict(fresh.get("extra") or {}) cur_extra.pop("tag", None) extra: dict | None = cur_extra else: extra = None async with _translate_spoolman_errors(): updated = await client.update_spool_full( spool_id=spool_id, filament_id=filament_id, remaining_weight=remaining, comment=note or "", price=data.cost_per_kg, extra=extra, location=storage_location or None, clear_location=storage_location_changed and not storage_location, ) return _map_spoolman_spool(updated) @router.delete("/spools/{spool_id}") async def delete_spool( spool_id: int = Path(..., gt=0), db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ) -> dict: """Permanently delete a spool from Spoolman.""" client = await _get_client(db) async with _translate_spoolman_errors(): await client.delete_spool(spool_id) return {"status": "deleted"} @router.post("/spools/{spool_id}/archive") async def archive_spool( spool_id: int = Path(..., gt=0), db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ) -> dict: """Archive a spool in Spoolman (soft-delete).""" client = await _get_client(db) async with _translate_spoolman_errors(): spool = await client.set_spool_archived(spool_id, archived=True) try: return _map_spoolman_spool(spool) except ValueError as exc: logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc) raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc @router.post("/spools/{spool_id}/restore") async def restore_spool( spool_id: int = Path(..., gt=0), db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ) -> dict: """Restore an archived spool in Spoolman.""" client = await _get_client(db) async with _translate_spoolman_errors(): spool = await client.set_spool_archived(spool_id, archived=False) try: return _map_spoolman_spool(spool) except ValueError as exc: logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc) raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc @router.patch("/spools/{spool_id}/weight") async def sync_spool_weight( *, spool_id: int = Path(..., gt=0), data: SpoolWeightUpdate, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ) -> dict: """Update a spool's remaining weight from a measured gross weight. Computes remaining = gross_weight - filament.spool_weight (empty-spool weight from Spoolman; falls back to 250 g when unset) and updates Spoolman accordingly. """ client = await _get_client(db) async with _translate_spoolman_errors(): current = await client.get_spool(spool_id) cur_filament = current.get("filament") or {} core_weight = _safe_float(cur_filament.get("spool_weight"), 250.0) remaining = max(0.0, data.weight_grams - core_weight) async with _translate_spoolman_errors(): updated = await client.update_spool_full(spool_id=spool_id, remaining_weight=remaining) upd_filament = updated.get("filament") or {} label_weight = _safe_int(upd_filament.get("weight"), 1000) weight_used = max(0.0, label_weight - remaining) return {"status": "ok", "weight_used": weight_used}