|
@@ -34,6 +34,7 @@ from backend.app.api.routes._spoolman_helpers import (
|
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
|
|
from backend.app.core.database import get_db
|
|
from backend.app.core.database import get_db
|
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.core.permissions import Permission
|
|
|
|
|
+from backend.app.core.websocket import ws_manager
|
|
|
from backend.app.models.ams_label import AmsLabel
|
|
from backend.app.models.ams_label import AmsLabel
|
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.models.printer import Printer
|
|
|
from backend.app.models.settings import Settings
|
|
from backend.app.models.settings import Settings
|
|
@@ -42,6 +43,11 @@ from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
|
|
from backend.app.models.user import User
|
|
from backend.app.models.user import User
|
|
|
from backend.app.schemas.spool import SpoolKProfileBase
|
|
from backend.app.schemas.spool import SpoolKProfileBase
|
|
|
from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
|
|
from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
|
|
|
|
|
+from backend.app.services.location_service import (
|
|
|
|
|
+ enrich_spool_dicts_with_location_id,
|
|
|
|
|
+ maybe_sync_spoolman_locations,
|
|
|
|
|
+ resolve_spoolman_location_string,
|
|
|
|
|
+)
|
|
|
from backend.app.services.printer_manager import printer_manager
|
|
from backend.app.services.printer_manager import printer_manager
|
|
|
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
|
|
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
|
|
|
from backend.app.services.spoolman import (
|
|
from backend.app.services.spoolman import (
|
|
@@ -307,6 +313,7 @@ class SpoolmanInventoryCreate(BaseModel):
|
|
|
note: str | None = Field(None, max_length=1000)
|
|
note: str | None = Field(None, max_length=1000)
|
|
|
cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
|
|
cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
|
|
|
storage_location: str | None = Field(None, max_length=255)
|
|
storage_location: str | None = Field(None, max_length=255)
|
|
|
|
|
+ location_id: int | None = Field(None, gt=0)
|
|
|
# BambuStudio slicer preset for this spool. Spoolman has no native field
|
|
# BambuStudio slicer preset for this spool. Spoolman has no native field
|
|
|
# for this, so we persist it under the bambu_slicer_filament[_name] keys
|
|
# for this, so we persist it under the bambu_slicer_filament[_name] keys
|
|
|
# in the spool's extra dict and read it back in _map_spoolman_spool.
|
|
# in the spool's extra dict and read it back in _map_spoolman_spool.
|
|
@@ -349,6 +356,7 @@ class SpoolmanInventoryUpdate(BaseModel):
|
|
|
tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
|
|
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]+$")
|
|
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)
|
|
storage_location: str | None = Field(None, max_length=255)
|
|
|
|
|
+ location_id: int | None = Field(None, gt=0)
|
|
|
# BambuStudio slicer preset — persisted to Spoolman extra dict (see Create
|
|
# BambuStudio slicer preset — persisted to Spoolman extra dict (see Create
|
|
|
# schema). Pass an empty string to clear; null/omitted leaves unchanged.
|
|
# schema). Pass an empty string to clear; null/omitted leaves unchanged.
|
|
|
slicer_filament: str | None = Field(None, max_length=128)
|
|
slicer_filament: str | None = Field(None, max_length=128)
|
|
@@ -430,6 +438,13 @@ async def list_spools(
|
|
|
) -> list[dict]:
|
|
) -> list[dict]:
|
|
|
"""Return all Spoolman spools in the InventorySpool format."""
|
|
"""Return all Spoolman spools in the InventorySpool format."""
|
|
|
client = await _get_client(db)
|
|
client = await _get_client(db)
|
|
|
|
|
+ # Sync after we have the route-resolved client so tests that patch the
|
|
|
|
|
+ # route module's get_spoolman_client/init_spoolman_client also catch the
|
|
|
|
|
+ # sync's client lookup — otherwise the location_service path imports from
|
|
|
|
|
+ # backend.app.services.spoolman directly and bypasses the patch.
|
|
|
|
|
+ if await maybe_sync_spoolman_locations(db, client=client):
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
async with _translate_spoolman_errors():
|
|
async with _translate_spoolman_errors():
|
|
|
spools = await client.get_all_spools(allow_archived=include_archived)
|
|
spools = await client.get_all_spools(allow_archived=include_archived)
|
|
|
|
|
|
|
@@ -451,6 +466,7 @@ async def list_spools(
|
|
|
for m in mapped:
|
|
for m in mapped:
|
|
|
m["k_profiles"] = kp_by_spool.get(m["id"], [])
|
|
m["k_profiles"] = kp_by_spool.get(m["id"], [])
|
|
|
|
|
|
|
|
|
|
+ await enrich_spool_dicts_with_location_id(db, mapped)
|
|
|
return mapped
|
|
return mapped
|
|
|
|
|
|
|
|
|
|
|
|
@@ -472,6 +488,7 @@ async def get_spool(
|
|
|
|
|
|
|
|
kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
|
|
kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
|
|
|
mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()]
|
|
mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()]
|
|
|
|
|
+ await enrich_spool_dicts_with_location_id(db, [mapped])
|
|
|
return mapped
|
|
return mapped
|
|
|
|
|
|
|
|
|
|
|
|
@@ -507,6 +524,18 @@ async def create_spool(
|
|
|
client = await _get_client(db)
|
|
client = await _get_client(db)
|
|
|
filament_id = await _resolve_filament_id(data, client)
|
|
filament_id = await _resolve_filament_id(data, client)
|
|
|
|
|
|
|
|
|
|
+ storage_location = data.storage_location
|
|
|
|
|
+ if "location_id" in data.model_fields_set or "storage_location" in data.model_fields_set:
|
|
|
|
|
+ try:
|
|
|
|
|
+ storage_location, _ = await resolve_spoolman_location_string(
|
|
|
|
|
+ db,
|
|
|
|
|
+ location_id=data.location_id,
|
|
|
|
|
+ storage_location=data.storage_location,
|
|
|
|
|
+ fields_set=set(data.model_fields_set),
|
|
|
|
|
+ )
|
|
|
|
|
+ except ValueError as exc:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
+
|
|
|
remaining = max(0.0, data.label_weight - data.weight_used)
|
|
remaining = max(0.0, data.label_weight - data.weight_used)
|
|
|
try:
|
|
try:
|
|
|
async with _translate_spoolman_errors():
|
|
async with _translate_spoolman_errors():
|
|
@@ -514,7 +543,7 @@ async def create_spool(
|
|
|
filament_id=filament_id,
|
|
filament_id=filament_id,
|
|
|
remaining_weight=remaining,
|
|
remaining_weight=remaining,
|
|
|
comment=data.note or None,
|
|
comment=data.note or None,
|
|
|
- location=data.storage_location or None,
|
|
|
|
|
|
|
+ location=storage_location or None,
|
|
|
)
|
|
)
|
|
|
except HTTPException as exc:
|
|
except HTTPException as exc:
|
|
|
if exc.status_code == 404 and data.spoolman_filament_id is not None:
|
|
if exc.status_code == 404 and data.spoolman_filament_id is not None:
|
|
@@ -556,6 +585,7 @@ async def create_spool(
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
result = _map_spoolman_spool(spool)
|
|
result = _map_spoolman_spool(spool)
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
if price_warnings:
|
|
if price_warnings:
|
|
|
return JSONResponse(status_code=207, content={**result, "warnings": price_warnings})
|
|
return JSONResponse(status_code=207, content={**result, "warnings": price_warnings})
|
|
|
return result
|
|
return result
|
|
@@ -581,6 +611,18 @@ async def bulk_create_spools(
|
|
|
) from exc
|
|
) from exc
|
|
|
raise
|
|
raise
|
|
|
|
|
|
|
|
|
|
+ storage_location = data.storage_location
|
|
|
|
|
+ if "location_id" in data.model_fields_set or "storage_location" in data.model_fields_set:
|
|
|
|
|
+ try:
|
|
|
|
|
+ storage_location, _ = await resolve_spoolman_location_string(
|
|
|
|
|
+ db,
|
|
|
|
|
+ location_id=data.location_id,
|
|
|
|
|
+ storage_location=data.storage_location,
|
|
|
|
|
+ fields_set=set(data.model_fields_set),
|
|
|
|
|
+ )
|
|
|
|
|
+ except ValueError as exc:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
+
|
|
|
remaining = max(0.0, data.label_weight - data.weight_used)
|
|
remaining = max(0.0, data.label_weight - data.weight_used)
|
|
|
created: list[dict] = []
|
|
created: list[dict] = []
|
|
|
failures: list[str] = []
|
|
failures: list[str] = []
|
|
@@ -590,7 +632,7 @@ async def bulk_create_spools(
|
|
|
filament_id=filament_id,
|
|
filament_id=filament_id,
|
|
|
remaining_weight=remaining,
|
|
remaining_weight=remaining,
|
|
|
comment=data.note or None,
|
|
comment=data.note or None,
|
|
|
- location=data.storage_location or None,
|
|
|
|
|
|
|
+ location=storage_location or None,
|
|
|
)
|
|
)
|
|
|
except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
|
|
except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
|
|
|
logger.warning("Bulk spool creation: one spool failed: %s", exc)
|
|
logger.warning("Bulk spool creation: one spool failed: %s", exc)
|
|
@@ -613,6 +655,8 @@ async def bulk_create_spools(
|
|
|
if not created:
|
|
if not created:
|
|
|
raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman")
|
|
raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman")
|
|
|
|
|
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
|
|
+
|
|
|
if len(created) < payload.quantity:
|
|
if len(created) < payload.quantity:
|
|
|
# Some spool creations failed — return 207 Multi-Status so the caller
|
|
# 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.
|
|
# can distinguish a full success from a partial one and show a useful message.
|
|
@@ -679,8 +723,18 @@ async def update_spool(
|
|
|
synthetic_used = float(current.get("used_weight") or 0)
|
|
synthetic_used = float(current.get("used_weight") or 0)
|
|
|
weight_used = data.weight_used if data.weight_used is not None else synthetic_used
|
|
weight_used = data.weight_used if data.weight_used is not None else synthetic_used
|
|
|
note = data.note if data.note is not None else current.get("comment")
|
|
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 None
|
|
|
|
|
|
|
+ storage_location_changed = "storage_location" in data.model_fields_set or "location_id" in data.model_fields_set
|
|
|
|
|
+ storage_location = data.storage_location if "storage_location" in data.model_fields_set else None
|
|
|
|
|
+ if storage_location_changed:
|
|
|
|
|
+ try:
|
|
|
|
|
+ storage_location, _ = await resolve_spoolman_location_string(
|
|
|
|
|
+ db,
|
|
|
|
|
+ location_id=data.location_id,
|
|
|
|
|
+ storage_location=storage_location,
|
|
|
|
|
+ fields_set=set(data.model_fields_set),
|
|
|
|
|
+ )
|
|
|
|
|
+ except ValueError as exc:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
color_hex = rgba[:6]
|
|
color_hex = rgba[:6]
|
|
|
|
|
|
|
@@ -817,6 +871,7 @@ async def update_spool(
|
|
|
async with _translate_spoolman_errors():
|
|
async with _translate_spoolman_errors():
|
|
|
updated = await client.merge_spool_extra(spool_id, new_extra)
|
|
updated = await client.merge_spool_extra(spool_id, new_extra)
|
|
|
|
|
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
return _map_spoolman_spool(updated)
|
|
return _map_spoolman_spool(updated)
|
|
|
|
|
|
|
|
|
|
|
|
@@ -830,6 +885,7 @@ async def delete_spool(
|
|
|
client = await _get_client(db)
|
|
client = await _get_client(db)
|
|
|
async with _translate_spoolman_errors():
|
|
async with _translate_spoolman_errors():
|
|
|
await client.delete_spool(spool_id)
|
|
await client.delete_spool(spool_id)
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
return {"status": "deleted"}
|
|
return {"status": "deleted"}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -844,10 +900,12 @@ async def archive_spool(
|
|
|
async with _translate_spoolman_errors():
|
|
async with _translate_spoolman_errors():
|
|
|
spool = await client.set_spool_archived(spool_id, archived=True)
|
|
spool = await client.set_spool_archived(spool_id, archived=True)
|
|
|
try:
|
|
try:
|
|
|
- return _map_spoolman_spool(spool)
|
|
|
|
|
|
|
+ mapped = _map_spoolman_spool(spool)
|
|
|
except ValueError as exc:
|
|
except ValueError as exc:
|
|
|
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, 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
|
|
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
|
|
+ return mapped
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/spools/{spool_id}/restore")
|
|
@router.post("/spools/{spool_id}/restore")
|
|
@@ -861,10 +919,12 @@ async def restore_spool(
|
|
|
async with _translate_spoolman_errors():
|
|
async with _translate_spoolman_errors():
|
|
|
spool = await client.set_spool_archived(spool_id, archived=False)
|
|
spool = await client.set_spool_archived(spool_id, archived=False)
|
|
|
try:
|
|
try:
|
|
|
- return _map_spoolman_spool(spool)
|
|
|
|
|
|
|
+ mapped = _map_spoolman_spool(spool)
|
|
|
except ValueError as exc:
|
|
except ValueError as exc:
|
|
|
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, 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
|
|
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
|
|
+ return mapped
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/spools/{spool_id}/reset-consumed-counter")
|
|
@router.post("/spools/{spool_id}/reset-consumed-counter")
|
|
@@ -888,10 +948,12 @@ async def reset_spool_consumed_counter(
|
|
|
async with _translate_spoolman_errors():
|
|
async with _translate_spoolman_errors():
|
|
|
spool = await client.reset_spool_usage(spool_id)
|
|
spool = await client.reset_spool_usage(spool_id)
|
|
|
try:
|
|
try:
|
|
|
- return _map_spoolman_spool(spool)
|
|
|
|
|
|
|
+ mapped = _map_spoolman_spool(spool)
|
|
|
except ValueError as exc:
|
|
except ValueError as exc:
|
|
|
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, 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
|
|
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
|
|
+ return mapped
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/spools/reset-consumed-counter-bulk")
|
|
@router.post("/spools/reset-consumed-counter-bulk")
|
|
@@ -922,6 +984,8 @@ async def bulk_reset_spool_consumed_counter(
|
|
|
reset_count += 1
|
|
reset_count += 1
|
|
|
except HTTPException as exc:
|
|
except HTTPException as exc:
|
|
|
logger.warning("Spoolman reset-consumed-counter failed for spool %s: %s", spool_id, exc.detail)
|
|
logger.warning("Spoolman reset-consumed-counter failed for spool %s: %s", spool_id, exc.detail)
|
|
|
|
|
+ if reset_count:
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
return {"reset": reset_count}
|
|
return {"reset": reset_count}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -955,6 +1019,7 @@ async def sync_spool_weight(
|
|
|
upd_filament = updated.get("filament") or {}
|
|
upd_filament = updated.get("filament") or {}
|
|
|
label_weight = _safe_int(upd_filament.get("weight"), 1000)
|
|
label_weight = _safe_int(upd_filament.get("weight"), 1000)
|
|
|
weight_used = max(0.0, label_weight - remaining)
|
|
weight_used = max(0.0, label_weight - remaining)
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
return {"status": "ok", "weight_used": weight_used}
|
|
return {"status": "ok", "weight_used": weight_used}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -997,6 +1062,7 @@ async def link_tag_to_spoolman_spool(
|
|
|
updated = await client.update_spool_full(spool_id=spool_id, extra=cur_extra)
|
|
updated = await client.update_spool_full(spool_id=spool_id, extra=cur_extra)
|
|
|
|
|
|
|
|
logger.info("Linked tag %s to Spoolman spool %s", tag, spool_id)
|
|
logger.info("Linked tag %s to Spoolman spool %s", tag, spool_id)
|
|
|
|
|
+ await ws_manager.broadcast({"type": "inventory_changed"})
|
|
|
return _map_spoolman_spool(updated)
|
|
return _map_spoolman_spool(updated)
|
|
|
|
|
|
|
|
|
|
|