Restrict printer secrets to update-authority callers

GET /api/v1/printers/ and /api/v1/printers/{id} return access_code
      only when the caller holds PRINTERS_UPDATE. Adds PrinterResponseWithSecret
      as the elevated response shape; PrinterResponse no longer carries the
      field. Auth-disabled single-trust mode preserved.
This commit is contained in:
maziggy
2026-06-28 12:31:11 +02:00
63 changed files with 3970 additions and 495 deletions
+4
View File
File diff suppressed because one or more lines are too long
@@ -55,6 +55,7 @@ class MappedSpoolFields(TypedDict):
updated_at: str | None
cost_per_kg: float | None
storage_location: str | None
location_id: int | None
k_profiles: list[Any]
@@ -346,5 +347,6 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
"updated_at": created_at,
"cost_per_kg": _safe_optional_float(spool.get("price")),
"storage_location": spool.get("location") or None,
"location_id": None,
"k_profiles": [],
}
+222 -2
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -20,11 +21,14 @@ 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.color_catalog import ColorCatalogEntry
from backend.app.models.location import Location
from backend.app.models.settings import Settings
from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.spool_catalog import SpoolCatalogEntry
from backend.app.models.spool_k_profile import SpoolKProfile
from backend.app.models.user import User
from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
from backend.app.schemas.spool import (
SpoolAssignmentCreate,
SpoolAssignmentResponse,
@@ -38,6 +42,16 @@ from backend.app.schemas.spool import (
normalize_extra_colors,
)
from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
from backend.app.services.location_service import (
DUPLICATE_LOCATION_NAME,
assign_location_name,
count_internal_spools_at_location,
get_location_by_id,
get_location_by_name,
location_name_key,
prepare_internal_spool_payload,
rename_location as rename_location_record,
)
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
from backend.app.services.spool_csv import (
MAX_CSV_IMPORT_BYTES,
@@ -46,6 +60,7 @@ from backend.app.services.spool_csv import (
parse_and_validate,
serialize,
)
from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
from backend.app.utils.filament_ids import (
GENERIC_FILAMENT_IDS,
MATERIAL_TEMPS,
@@ -493,6 +508,198 @@ async def reset_spool_catalog(
return {"status": "reset"}
# ── Storage Locations (#1004) ───────────────────────────────────────────────
async def _load_settings_map(db: AsyncSession) -> dict[str, str]:
result = await db.execute(select(Settings))
return {s.key: s.value for s in result.scalars().all()}
def _spoolman_is_enabled(settings: dict[str, str]) -> bool:
return settings.get("spoolman_enabled", "false").lower() == "true"
async def _ensure_spoolman_client(settings: dict[str, str]) -> SpoolmanClient | None:
if not _spoolman_is_enabled(settings):
return None
url = settings.get("spoolman_url", "").strip()
if not url:
return None
from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
try:
assert_safe_spoolman_url(url)
except ValueError:
return None
client = await get_spoolman_client()
if not client or client.base_url != url.rstrip("/"):
client = await init_spoolman_client(url)
return client
async def _spool_counts_for_locations(
db: AsyncSession,
locations: list[Location],
settings: dict[str, str],
) -> dict[int, int]:
if _spoolman_is_enabled(settings):
client = await _ensure_spoolman_client(settings)
if client:
try:
spools = await client.get_all_spools(allow_archived=False)
except Exception:
logger.warning("Failed to fetch Spoolman spools for location counts", exc_info=True)
else:
# Use the canonical key helper so this matches what the
# migration backfill, Location.name_key, and every other
# codepath store as the case-insensitive lookup key. Plain
# str.lower() drifts for non-ASCII (Turkish ı/İ, German ß)
# and caused mismatched delete-block counts in Spoolman mode.
by_key: dict[str, int] = {}
for spool in spools:
raw = spool.get("location")
if not raw or not isinstance(raw, str) or not raw.strip():
continue
try:
key = location_name_key(raw)
except ValueError:
continue
by_key[key] = by_key.get(key, 0) + 1
return {loc.id: by_key.get(loc.name_key, 0) for loc in locations}
counts: dict[int, int] = {}
for loc in locations:
counts[loc.id] = await count_internal_spools_at_location(db, loc.id)
return counts
def _location_to_response(location: Location, spool_count: int) -> LocationResponse:
return LocationResponse(
id=location.id,
name=location.name,
identifier=location.identifier,
spool_count=spool_count,
created_at=location.created_at,
updated_at=location.updated_at,
)
@router.get("/locations", response_model=list[LocationResponse])
async def list_locations(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""List all storage locations with spool counts."""
settings = await _load_settings_map(db)
result = await db.execute(select(Location).order_by(Location.name))
locations = list(result.scalars().all())
counts = await _spool_counts_for_locations(db, locations, settings)
return [_location_to_response(loc, counts.get(loc.id, 0)) for loc in locations]
@router.post("/locations", response_model=LocationResponse, status_code=201)
async def create_location(
data: LocationCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Create a storage location."""
existing = await get_location_by_name(db, data.name)
if existing:
raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME)
location = Location(identifier=data.identifier)
assign_location_name(location, data.name)
db.add(location)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME) from exc
await db.refresh(location)
await ws_manager.broadcast({"type": "inventory_changed"})
return _location_to_response(location, 0)
@router.patch("/locations/{location_id}", response_model=LocationResponse)
async def update_location(
location_id: int,
data: LocationUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Update a storage location (rename propagates to assigned spools)."""
location = await get_location_by_id(db, location_id)
if not location:
raise HTTPException(status_code=404, detail="Location not found")
old_name = location.name
if data.identifier is not None:
location.identifier = data.identifier or None
if data.name is not None and data.name != old_name:
try:
await rename_location_record(db, location, data.name)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# Cascade to Spoolman BEFORE the local commit so a Spoolman failure
# rolls back the local rename instead of leaving the catalog and
# Spoolman's per-spool `location` field permanently diverged. Without
# this ordering, a partial failure makes the next location-sync recreate
# the old name as a duplicate catalog row (#1505 review blocker).
settings = await _load_settings_map(db)
client = await _ensure_spoolman_client(settings)
if client:
try:
await client.rename_location(old_name, location.name)
except Exception as exc:
logger.warning(
"Spoolman location rename failed for %s -> %s: %s",
old_name,
location.name,
exc,
)
await db.rollback()
raise HTTPException(
status_code=502,
detail="Spoolman rename failed; local rename rolled back",
) from exc
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME) from exc
await db.refresh(location)
settings = await _load_settings_map(db)
counts = await _spool_counts_for_locations(db, [location], settings)
await ws_manager.broadcast({"type": "inventory_changed"})
return _location_to_response(location, counts.get(location.id, 0))
@router.delete("/locations/{location_id}")
async def delete_location(
location_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Delete a storage location when no spools are assigned."""
location = await get_location_by_id(db, location_id)
if not location:
raise HTTPException(status_code=404, detail="Location not found")
settings = await _load_settings_map(db)
counts = await _spool_counts_for_locations(db, [location], settings)
if counts.get(location.id, 0) > 0:
raise HTTPException(status_code=409, detail="Location has spools assigned and cannot be deleted")
await db.delete(location)
await db.commit()
await ws_manager.broadcast({"type": "inventory_changed"})
return {"status": "deleted"}
# ── Color Catalog CRUD ─────────────────────────────────────────────────────
@@ -995,7 +1202,11 @@ async def create_spool(
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Create a new spool."""
spool = Spool(**spool_data.model_dump())
try:
payload = await prepare_internal_spool_payload(db, spool_data.model_dump(), set(spool_data.model_fields_set))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
spool = Spool(**payload)
db.add(spool)
await db.commit()
await db.refresh(spool)
@@ -1012,8 +1223,13 @@ async def bulk_create_spools(
):
"""Create multiple identical spools."""
spools = []
fields_set = set(data.spool.model_fields_set)
try:
payload = await prepare_internal_spool_payload(db, data.spool.model_dump(), fields_set)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for _ in range(data.quantity):
spool = Spool(**data.spool.model_dump())
spool = Spool(**payload)
db.add(spool)
spools.append(spool)
await db.commit()
@@ -1037,6 +1253,10 @@ async def update_spool(
raise HTTPException(404, "Spool not found")
update_data = spool_data.model_dump(exclude_unset=True)
try:
update_data = await prepare_internal_spool_payload(db, update_data, set(spool_data.model_fields_set))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Auto-lock weight when user explicitly sets weight_used
if "weight_used" in update_data and "weight_locked" not in update_data:
update_data["weight_locked"] = True
+57 -9
View File
@@ -8,7 +8,11 @@ from fastapi.responses import Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
from backend.app.core.auth import (
RequireCameraStreamTokenIfAuthEnabled,
RequirePermissionIfAuthEnabled,
is_auth_enabled,
)
from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
@@ -16,6 +20,7 @@ from backend.app.core.tasks import spawn_background_task
from backend.app.models.ams_label import AmsLabel
from backend.app.models.printer import Printer
from backend.app.models.slot_preset import SlotPresetMapping
from backend.app.models.user import User
from backend.app.schemas.printer import (
AmsLabelBody,
AMSTray,
@@ -28,6 +33,7 @@ from backend.app.schemas.printer import (
PrinterCreate,
PrinterDiagnosticResult,
PrinterResponse,
PrinterResponseWithSecret,
PrinterStatus,
PrinterUpdate,
PrintOptionsResponse,
@@ -55,14 +61,50 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/printers", tags=["printers"])
@router.get("/", response_model=list[PrinterResponse])
async def _caller_can_view_printer_secrets(user: User | None, db: AsyncSession) -> bool:
"""Whether the caller is trusted enough to see ``access_code`` on a printer
response. Fail-CLOSED: anything that isn't an authenticated user holding
PRINTERS_UPDATE returns False.
- Auth disabled → True (single trust domain — same as today's local UI).
- JWT user with PRINTERS_UPDATE → True (Admin or Operator; the same roles
that already manage printers and the Virtual Printer card UX that
surfaces a target's code for slicer configuration).
- JWT Viewer → False (the bug fix: Viewers must not be able to read
access_code via PRINTERS_READ and then go around Bambuddy to MQTT).
- API-key principal (``user is None`` because the dep returns None for
API keys) → False. PRINTERS_UPDATE is admin-only and absent from
``_APIKEY_SCOPE_BY_PERMISSION``, so no API key can hold it.
"""
if not await is_auth_enabled(db):
return True
if user is None:
return False
return user.has_permission(Permission.PRINTERS_UPDATE.value)
def _serialize_printer(printer: Printer, *, include_secret: bool):
"""Build the response shape that matches the caller's authority."""
if include_secret:
return PrinterResponseWithSecret.model_validate(printer)
return PrinterResponse.model_validate(printer)
@router.get("/")
async def list_printers(
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""List all configured printers."""
"""List all configured printers.
``access_code`` is included in each item only when the caller is trusted
to see it (Admin / Operator JWT, or auth-disabled mode). Viewers and
API keys never receive it.
"""
result = await db.execute(select(Printer).order_by(Printer.name))
return list(result.scalars().all())
printers = list(result.scalars().all())
include_secret = await _caller_can_view_printer_secrets(user, db)
return [_serialize_printer(p, include_secret=include_secret) for p in printers]
@router.post("/", response_model=PrinterResponse)
@@ -262,18 +304,24 @@ async def get_developer_mode_warnings(
return warnings
@router.get("/{printer_id}", response_model=PrinterResponse)
@router.get("/{printer_id}")
async def get_printer(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get a specific printer."""
"""Get a specific printer.
``access_code`` is included only when the caller is trusted to see it
(Admin / Operator JWT, or auth-disabled mode). Viewers and API keys
never receive it.
"""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
return printer
include_secret = await _caller_can_view_printer_secrets(user, db)
return _serialize_printer(printer, include_secret=include_secret)
@router.patch("/{printer_id}", response_model=PrinterResponse)
+11
View File
@@ -446,9 +446,20 @@ async def update_spoolman_settings(
if "spoolman_report_partial_usage" in settings:
await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
spoolman_changed = (
"spoolman_enabled" in settings
or "spoolman_url" in settings
)
await db.commit()
db.expire_all()
if spoolman_changed:
from backend.app.services.location_service import maybe_sync_spoolman_locations
if await maybe_sync_spoolman_locations(db):
await db.commit()
# Return updated settings
return await get_spoolman_settings(db)
+73 -7
View File
@@ -34,6 +34,7 @@ from backend.app.api.routes._spoolman_helpers import (
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.core.websocket import ws_manager
from backend.app.models.ams_label import AmsLabel
from backend.app.models.printer import Printer
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.schemas.spool import SpoolKProfileBase
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.slicer_filament_resolver import resolve_slicer_filament
from backend.app.services.spoolman import (
@@ -307,6 +313,7 @@ class SpoolmanInventoryCreate(BaseModel):
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)
location_id: int | None = Field(None, gt=0)
# BambuStudio slicer preset for this spool. Spoolman has no native field
# 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.
@@ -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]+$")
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)
location_id: int | None = Field(None, gt=0)
# BambuStudio slicer preset — persisted to Spoolman extra dict (see Create
# schema). Pass an empty string to clear; null/omitted leaves unchanged.
slicer_filament: str | None = Field(None, max_length=128)
@@ -430,6 +438,13 @@ async def list_spools(
) -> list[dict]:
"""Return all Spoolman spools in the InventorySpool format."""
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():
spools = await client.get_all_spools(allow_archived=include_archived)
@@ -451,6 +466,7 @@ async def list_spools(
for m in mapped:
m["k_profiles"] = kp_by_spool.get(m["id"], [])
await enrich_spool_dicts_with_location_id(db, 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))
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
@@ -507,6 +524,18 @@ async def create_spool(
client = await _get_client(db)
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)
try:
async with _translate_spoolman_errors():
@@ -514,7 +543,7 @@ async def create_spool(
filament_id=filament_id,
remaining_weight=remaining,
comment=data.note or None,
location=data.storage_location or None,
location=storage_location or None,
)
except HTTPException as exc:
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)
await ws_manager.broadcast({"type": "inventory_changed"})
if price_warnings:
return JSONResponse(status_code=207, content={**result, "warnings": price_warnings})
return result
@@ -581,6 +611,18 @@ async def bulk_create_spools(
) from exc
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)
created: list[dict] = []
failures: list[str] = []
@@ -590,7 +632,7 @@ async def bulk_create_spools(
filament_id=filament_id,
remaining_weight=remaining,
comment=data.note or None,
location=data.storage_location or None,
location=storage_location or None,
)
except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
logger.warning("Bulk spool creation: one spool failed: %s", exc)
@@ -613,6 +655,8 @@ async def bulk_create_spools(
if not created:
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:
# 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.
@@ -679,8 +723,18 @@ async def update_spool(
synthetic_used = float(current.get("used_weight") or 0)
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")
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]
@@ -817,6 +871,7 @@ async def update_spool(
async with _translate_spoolman_errors():
updated = await client.merge_spool_extra(spool_id, new_extra)
await ws_manager.broadcast({"type": "inventory_changed"})
return _map_spoolman_spool(updated)
@@ -830,6 +885,7 @@ async def delete_spool(
client = await _get_client(db)
async with _translate_spoolman_errors():
await client.delete_spool(spool_id)
await ws_manager.broadcast({"type": "inventory_changed"})
return {"status": "deleted"}
@@ -844,10 +900,12 @@ async def archive_spool(
async with _translate_spoolman_errors():
spool = await client.set_spool_archived(spool_id, archived=True)
try:
return _map_spoolman_spool(spool)
mapped = _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
await ws_manager.broadcast({"type": "inventory_changed"})
return mapped
@router.post("/spools/{spool_id}/restore")
@@ -861,10 +919,12 @@ async def restore_spool(
async with _translate_spoolman_errors():
spool = await client.set_spool_archived(spool_id, archived=False)
try:
return _map_spoolman_spool(spool)
mapped = _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
await ws_manager.broadcast({"type": "inventory_changed"})
return mapped
@router.post("/spools/{spool_id}/reset-consumed-counter")
@@ -888,10 +948,12 @@ async def reset_spool_consumed_counter(
async with _translate_spoolman_errors():
spool = await client.reset_spool_usage(spool_id)
try:
return _map_spoolman_spool(spool)
mapped = _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
await ws_manager.broadcast({"type": "inventory_changed"})
return mapped
@router.post("/spools/reset-consumed-counter-bulk")
@@ -922,6 +984,8 @@ async def bulk_reset_spool_consumed_counter(
reset_count += 1
except HTTPException as exc:
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}
@@ -955,6 +1019,7 @@ async def sync_spool_weight(
upd_filament = updated.get("filament") or {}
label_weight = _safe_int(upd_filament.get("weight"), 1000)
weight_used = max(0.0, label_weight - remaining)
await ws_manager.broadcast({"type": "inventory_changed"})
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)
logger.info("Linked tag %s to Spoolman spool %s", tag, spool_id)
await ws_manager.broadcast({"type": "inventory_changed"})
return _map_spoolman_spool(updated)
+105
View File
@@ -181,6 +181,7 @@ async def init_db():
kprofile_note,
library,
local_preset,
location,
long_lived_token,
maintenance,
notification,
@@ -2938,6 +2939,110 @@ async def run_migrations(conn):
)
)
# Migration: structured storage locations (#1004). Flat catalog of physical
# shelves/drawers; spool.location_id FK with storage_location kept denormalized.
await _safe_execute(
conn,
"""
CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL UNIQUE,
name_key VARCHAR(255),
identifier VARCHAR(100),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
if is_sqlite()
else """
CREATE TABLE IF NOT EXISTS locations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
name_key VARCHAR(255),
identifier VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
)
await _safe_execute(conn, "ALTER TABLE locations ADD COLUMN name_key VARCHAR(255)")
await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_locations_name_key ON locations (name_key)")
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN location_id INTEGER REFERENCES locations(id)")
await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_spool_location_id ON spool (location_id)")
# Backfill name_key on legacy rows FIRST. If a pre-existing locations
# row was manually inserted before this migration ran, its name_key is
# NULL. The dedup INSERT below would then be silently skipped by
# UNIQUE(name) (legacy row already has the name), AND the spool-link
# UPDATE that joins on name_key would miss it. Doing this backfill BEFORE
# the INSERT keeps the join consistent on both branches of the migration.
async with conn.begin_nested():
await conn.execute(
text(
"""
UPDATE locations
SET name_key = LOWER(TRIM(name))
WHERE name_key IS NULL OR TRIM(name_key) = ''
"""
)
)
# Backfill locations from existing free-text storage_location values.
# GROUP BY name_key so case variants ("Drybox 1" / "DRYBOX 1") collapse to
# one row; INSERT OR IGNORE / ON CONFLICT keeps the migration idempotent.
_location_backfill_sql = (
"""
INSERT OR IGNORE INTO locations (name, name_key, created_at, updated_at)
SELECT MIN(TRIM(storage_location)), LOWER(TRIM(storage_location)), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM spool
WHERE TRIM(COALESCE(storage_location, '')) != ''
GROUP BY LOWER(TRIM(storage_location))
"""
if is_sqlite()
else """
INSERT INTO locations (name, name_key, created_at, updated_at)
SELECT MIN(TRIM(storage_location)), LOWER(TRIM(storage_location)), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM spool
WHERE TRIM(COALESCE(storage_location, '')) != ''
GROUP BY LOWER(TRIM(storage_location))
ON CONFLICT (name_key) DO NOTHING
"""
)
async with conn.begin_nested():
await conn.execute(text(_location_backfill_sql))
await conn.execute(
text(
"""
UPDATE spool
SET location_id = (
SELECT l.id FROM locations l
WHERE l.name_key = LOWER(TRIM(spool.storage_location))
LIMIT 1
)
WHERE TRIM(COALESCE(storage_location, '')) != ''
AND location_id IS NULL
"""
)
)
# Sanity check: any spools that still have a free-text storage_location
# but no location_id link mean a row slipped through the dedup INSERT
# (most likely a pre-existing manually-inserted locations row with a
# hostile name shape that the UNIQUE(name) check tripped on). Surface
# the count so ops can investigate — the user won't see those spools in
# location-filtered queries until they're manually linked or re-saved.
orphan_count_row = await conn.execute(
text("SELECT COUNT(*) FROM spool WHERE TRIM(COALESCE(storage_location, '')) != '' AND location_id IS NULL")
)
orphan_count = orphan_count_row.scalar() or 0
if orphan_count:
logger.warning(
"Storage-location migration left %d spool(s) with free-text storage_location "
"but no location_id link. Re-save those spools or merge the orphaned location "
"names manually.",
orphan_count,
)
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
+2
View File
@@ -10,6 +10,7 @@ from backend.app.models.group import Group, user_groups
from backend.app.models.kprofile_note import KProfileNote
from backend.app.models.library import LibraryFile, LibraryFolder
from backend.app.models.local_preset import LocalPreset
from backend.app.models.location import Location
from backend.app.models.long_lived_token import LongLivedToken
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
from backend.app.models.notification import NotificationLog
@@ -55,6 +56,7 @@ __all__ = [
"PrintBatch",
"LibraryFolder",
"LibraryFile",
"Location",
"User",
"Group",
"user_groups",
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
if TYPE_CHECKING:
from backend.app.models.spool import Spool
class Location(Base):
"""Physical storage location for filament spools (shelf, drawer, drybox, etc.)."""
__tablename__ = "locations"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
# Case-insensitive uniqueness — LOWER(TRIM(name)); enforced via migration index.
name_key: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
# Reserved for Phase 3 RFID shelf tags — unused in Phase 1.
identifier: Mapped[str | None] = mapped_column(String(100))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
spools: Mapped[list["Spool"]] = relationship(back_populates="location")
+4 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, Integer, String, func
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
@@ -61,6 +61,7 @@ class Spool(Base):
cost_per_kg: Mapped[float | None] = mapped_column(Float) # Cost per kilogram
storage_location: Mapped[str | None] = mapped_column(String(255)) # User-editable storage location
location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), index=True)
last_used: Mapped[datetime | None] = mapped_column(DateTime) # Last time this spool was used in a print
encode_time: Mapped[datetime | None] = mapped_column(DateTime) # When spool was encoded/written to tag
@@ -74,7 +75,9 @@ class Spool(Base):
k_profiles: Mapped[list["SpoolKProfile"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
location: Mapped["Location | None"] = relationship(back_populates="spools")
from backend.app.models.location import Location # noqa: E402
from backend.app.models.spool_assignment import SpoolAssignment # noqa: E402
from backend.app.models.spool_k_profile import SpoolKProfile # noqa: E402
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from backend.app.services.location_service import normalize_location_name
class LocationCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
identifier: str | None = Field(default=None, max_length=100)
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
return normalize_location_name(v)
class LocationUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
identifier: str | None = Field(default=None, max_length=100)
@field_validator("name")
@classmethod
def validate_name(cls, v: str | None) -> str | None:
if v is None:
return None
return normalize_location_name(v)
class LocationResponse(BaseModel):
id: int
name: str
identifier: str | None = None
spool_count: int = 0
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
+16 -3
View File
@@ -29,7 +29,6 @@ class PrinterBase(BaseModel):
max_length=253,
pattern=r"^(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)$",
)
access_code: str = Field(..., min_length=1, max_length=20)
model: str | None = None
location: str | None = None # Group/location name
auto_archive: bool = True
@@ -41,7 +40,10 @@ class PrinterBase(BaseModel):
class PrinterCreate(PrinterBase):
pass
# access_code lives on the input shapes only — never on the default
# PrinterResponse. Direct exposure on PRINTERS_READ would let a Viewer
# connect to the printer's MQTT and bypass Bambuddy's RBAC.
access_code: str = Field(..., min_length=1, max_length=20)
class PlateDetectionROI(BaseModel):
@@ -101,7 +103,6 @@ class PrinterResponse(PrinterBase):
"name": printer.name,
"serial_number": printer.serial_number,
"ip_address": printer.ip_address,
"access_code": printer.access_code,
"model": printer.model,
"location": printer.location,
"auto_archive": printer.auto_archive,
@@ -135,6 +136,18 @@ class PrinterResponse(PrinterBase):
return cls(**data)
class PrinterResponseWithSecret(PrinterResponse):
"""PrinterResponse + access_code. Returned ONLY to callers with
PRINTERS_UPDATE (Admin / Operator JWTs, or single-trust auth-disabled mode).
Viewers and API keys never receive this shape — they get the bare
PrinterResponse without access_code, since holding the access_code lets
the caller talk to the printer's MQTT directly and bypass Bambuddy's RBAC.
"""
access_code: str
class HMSErrorResponse(BaseModel):
code: str
attr: int = 0 # Attribute value for constructing wiki URL
+5
View File
@@ -533,6 +533,11 @@ class AppSettingsUpdate(BaseModel):
raise ValueError("default_sidebar_order must be valid JSON or empty")
if isinstance(parsed, dict):
order = parsed.get("order")
hidden_system_item_ids = parsed.get("hiddenSystemItemIds", [])
if not isinstance(hidden_system_item_ids, list) or not all(
isinstance(item, str) for item in hidden_system_item_ids
):
raise ValueError("sidebar hidden system item IDs must be an array of strings")
elif isinstance(parsed, list):
order = parsed
else:
+2
View File
@@ -125,6 +125,7 @@ class SpoolBase(BaseModel):
# assignment). Column has lived on the ORM since the inventory rework
# but was missing from this schema, so writes were silently dropped (#1291).
storage_location: str | None = Field(default=None, max_length=255)
location_id: int | None = Field(default=None, gt=0)
class SpoolCreate(SpoolBase):
@@ -174,6 +175,7 @@ class SpoolUpdate(BaseModel):
category: str | None = Field(default=None, max_length=50)
low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
storage_location: str | None = Field(default=None, max_length=255)
location_id: int | None = Field(default=None, gt=0)
class SpoolKProfileBase(BaseModel):
+354
View File
@@ -0,0 +1,354 @@
"""Storage location catalog — single write path for spool location fields (#1004)."""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
import httpx
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.location import Location
from backend.app.models.spool import Spool
logger = logging.getLogger(__name__)
DUPLICATE_LOCATION_NAME = "A location with this name already exists"
def normalize_location_name(name: str) -> str:
trimmed = name.strip()
if not trimmed:
raise ValueError("name must not be empty")
return trimmed
def location_name_key(name: str) -> str:
"""Case-insensitive lookup key stored on Location.name_key."""
return normalize_location_name(name).lower()
def assign_location_name(location: Location, name: str) -> None:
normalized = normalize_location_name(name)
location.name = normalized
location.name_key = location_name_key(normalized)
@dataclass(frozen=True)
class SpoolLocationFields:
"""Canonical spool location state: FK + denormalized string for Spoolman/display."""
location_id: int | None
storage_location: str | None
async def get_location_by_id(db: AsyncSession, location_id: int) -> Location | None:
result = await db.execute(select(Location).where(Location.id == location_id))
return result.scalar_one_or_none()
async def get_location_by_name(db: AsyncSession, name: str) -> Location | None:
key = location_name_key(name)
result = await db.execute(select(Location).where(Location.name_key == key))
return result.scalar_one_or_none()
async def get_locations_by_name_keys(db: AsyncSession, keys: set[str]) -> dict[str, Location]:
if not keys:
return {}
result = await db.execute(select(Location).where(Location.name_key.in_(keys)))
return {loc.name_key: loc for loc in result.scalars().all()}
async def _create_location_or_get_existing(db: AsyncSession, normalized: str) -> Location:
"""Insert a location row, returning the winner on concurrent name_key collision."""
existing = await get_location_by_name(db, normalized)
if existing:
return existing
location = Location()
assign_location_name(location, normalized)
try:
async with db.begin_nested():
db.add(location)
await db.flush()
return location
except IntegrityError as exc:
winner = await get_location_by_name(db, normalized)
if winner:
return winner
raise ValueError(DUPLICATE_LOCATION_NAME) from exc
async def _insert_location_if_absent(db: AsyncSession, name: str) -> bool:
"""Stage a new location row when absent. Returns True when one was added."""
normalized = normalize_location_name(name)
if await get_location_by_name(db, normalized):
return False
location = Location()
assign_location_name(location, normalized)
try:
async with db.begin_nested():
db.add(location)
await db.flush()
return True
except IntegrityError:
# Race: another writer inserted the same name between our check and
# flush. The row already exists by definition — surface as "not added"
# rather than re-raising. Anything else (NULL constraint, FK, check
# constraint) would be a programming bug — re-fetch to verify so we
# don't silently drop unrelated IntegrityErrors.
if await get_location_by_name(db, normalized):
return False
logger.warning("IntegrityError on insert of location %r without surviving row", normalized)
raise
async def resolve_location_by_name(db: AsyncSession, name: str, *, create: bool = True) -> Location | None:
"""Find a location by name (case-insensitive), optionally creating it."""
normalized = normalize_location_name(name)
existing = await get_location_by_name(db, normalized)
if existing:
return existing
if not create:
return None
return await _create_location_or_get_existing(db, normalized)
async def resolve_spool_location_fields(
db: AsyncSession,
*,
location_id: int | None = None,
storage_location: str | None = None,
fields_set: set[str],
) -> SpoolLocationFields | None:
"""Resolve location_id + storage_location from API input.
``location_id`` wins when both fields appear in ``fields_set``.
Returns ``None`` when neither location field was provided.
"""
if "location_id" in fields_set:
if location_id is None:
return SpoolLocationFields(location_id=None, storage_location=None)
loc = await get_location_by_id(db, location_id)
if not loc:
raise ValueError(f"Location {location_id} not found")
return SpoolLocationFields(location_id=loc.id, storage_location=loc.name)
if "storage_location" in fields_set:
if not storage_location:
return SpoolLocationFields(location_id=None, storage_location=None)
loc = await resolve_location_by_name(db, storage_location)
if not loc:
return SpoolLocationFields(location_id=None, storage_location=None)
return SpoolLocationFields(location_id=loc.id, storage_location=loc.name)
return None
async def prepare_internal_spool_payload(db: AsyncSession, data: dict, fields_set: set[str]) -> dict:
"""Apply resolved location fields before creating or updating an internal spool."""
payload = dict(data)
resolved = await resolve_spool_location_fields(
db,
location_id=payload.get("location_id"),
storage_location=payload.get("storage_location"),
fields_set=fields_set,
)
if resolved is not None:
payload["location_id"] = resolved.location_id
payload["storage_location"] = resolved.storage_location
return payload
async def resolve_spoolman_location_string(
db: AsyncSession,
*,
location_id: int | None = None,
storage_location: str | None = None,
fields_set: set[str],
) -> tuple[str | None, bool]:
"""Return (Spoolman location string, changed) for proxy writes."""
resolved = await resolve_spool_location_fields(
db,
location_id=location_id,
storage_location=storage_location,
fields_set=fields_set,
)
if resolved is None:
return None, False
return resolved.storage_location, True
async def count_internal_spools_at_location(db: AsyncSession, location_id: int) -> int:
result = await db.execute(
select(func.count())
.select_from(Spool)
.where(
Spool.location_id == location_id,
Spool.archived_at.is_(None),
)
)
return int(result.scalar() or 0)
async def count_spools_at_location_by_name(db: AsyncSession, name: str) -> int:
normalized = name.strip()
if not normalized:
return 0
result = await db.execute(
select(func.count())
.select_from(Spool)
.where(
Spool.archived_at.is_(None),
func.lower(func.trim(Spool.storage_location)) == normalized.lower(),
)
)
return int(result.scalar() or 0)
async def enrich_spool_dicts_with_location_id(db: AsyncSession, spools: list[dict]) -> None:
"""Attach location_id to mapped Spoolman-style spool dicts in place."""
keys = {location_name_key(s["storage_location"]) for s in spools if (s.get("storage_location") or "").strip()}
if not keys:
for s in spools:
s["location_id"] = None
return
by_key = await get_locations_by_name_keys(db, keys)
for s in spools:
raw = (s.get("storage_location") or "").strip()
if not raw:
s["location_id"] = None
continue
loc = by_key.get(location_name_key(raw))
s["location_id"] = loc.id if loc else None
async def rename_location(db: AsyncSession, location: Location, new_name: str) -> Location:
normalized = normalize_location_name(new_name)
existing = await get_location_by_name(db, normalized)
if existing and existing.id != location.id:
raise ValueError(DUPLICATE_LOCATION_NAME)
old_name = location.name
# Mirror the SQL TRIM on the Python side so a legacy row whose
# `storage_location` has trailing whitespace still matches against the
# `old_name` we just lifted off the Location row. Without `.strip()` the
# equality is asymmetric (SQL strips the column; Python doesn't) and
# legacy rows quietly fall out of the rename cascade.
old_name_key = old_name.strip().lower()
assign_location_name(location, normalized)
await db.execute(update(Spool).where(Spool.location_id == location.id).values(storage_location=normalized))
# Keep legacy rows in sync when only storage_location was set.
await db.execute(
update(Spool)
.where(
Spool.location_id.is_(None),
func.lower(func.trim(Spool.storage_location)) == old_name_key,
)
.values(storage_location=normalized, location_id=location.id)
)
try:
await db.flush()
except IntegrityError as exc:
raise ValueError(DUPLICATE_LOCATION_NAME) from exc
return location
async def sync_locations_from_spoolman(db: AsyncSession, client) -> bool:
"""Import distinct Spoolman location strings into the local catalog.
Returns True when new rows were staged (caller must commit). Logs and
returns False on Spoolman fetch failures so the calling read path keeps
serving the local catalog instead of 500ing; bare-Exception swallow used
to be the shape here and hid both transport errors and shape regressions.
"""
from backend.app.services.spoolman import SpoolmanClientError, SpoolmanUnavailableError
try:
names = await client.get_distinct_locations()
except (SpoolmanUnavailableError, SpoolmanClientError, httpx.HTTPError) as exc:
logger.warning("location sync from Spoolman failed: %s", exc)
return False
# Collapse case variants before insert — Spoolman may return both
# "Drybox 1" and "DRYBOX 1" in the same payload.
by_key: dict[str, str] = {}
for raw in names:
name = (raw or "").strip()
if not name:
continue
key = location_name_key(name)
if key not in by_key:
by_key[key] = name
changed = False
for name in by_key.values():
if await _insert_location_if_absent(db, name):
changed = True
return changed
# Per-URL last-sync timestamp guard. Calling list_spools runs the sync, so on
# a polling UI without this guard every refetch round-trips to Spoolman and
# opens a write transaction — measurable latency and SQLite write contention.
# 60s is long enough to absorb dashboard polling, short enough that a manual
# spool rename in Spoolman shows up on the next minute's refresh.
_SPOOLMAN_LOCATION_SYNC_TTL_SECONDS = 60.0
_spoolman_location_sync_last_run: dict[str, float] = {}
def _spoolman_location_sync_cache_clear() -> None:
"""Test hook: drop the TTL cache so each test starts from a clean slate."""
_spoolman_location_sync_last_run.clear()
async def maybe_sync_spoolman_locations(db: AsyncSession, *, client=None) -> bool:
"""Sync Spoolman location names into the local catalog when integration is enabled.
Pass ``client`` when the caller has already resolved one (the GET /spools
route does); otherwise the function falls back to ``init_spoolman_client``.
Passing the route's client keeps test fixtures honest — without it, the
fall-back path imports from ``backend.app.services.spoolman`` directly and
bypasses any patch that targets the route module's alias, which causes
real TCP connects to whatever ``spoolman_url`` happens to point at.
"""
from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
from backend.app.models.settings import Settings
result = await db.execute(select(Settings))
settings = {s.key: s.value for s in result.scalars().all()}
if settings.get("spoolman_enabled", "false").lower() != "true":
return False
url = settings.get("spoolman_url", "").strip()
if not url:
return False
# Debounce: skip the round-trip when we synced this URL recently.
cache_key = url.rstrip("/")
last_run = _spoolman_location_sync_last_run.get(cache_key, 0.0)
now = time.monotonic()
if now - last_run < _SPOOLMAN_LOCATION_SYNC_TTL_SECONDS:
return False
try:
assert_safe_spoolman_url(url)
except ValueError as exc:
logger.warning("Spoolman URL rejected by SSRF guard during location sync: %s", exc)
return False
if client is None:
from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
client = await get_spoolman_client()
if not client or client.base_url != cache_key:
client = await init_spoolman_client(url)
if not client:
return False
changed = await sync_locations_from_spoolman(db, client)
_spoolman_location_sync_last_run[cache_key] = now
return changed
+80
View File
@@ -544,6 +544,86 @@ class SpoolmanClient:
params["allow_archived"] = "true"
return await self._get_with_retry("/spool", params=params or None)
async def get_distinct_locations(self) -> list[str]:
"""Return distinct location strings currently assigned to Spoolman spools.
Spoolman's `/location` endpoint shape varies across versions: older
releases return `list[str]`, newer ones return `list[dict]` with a
`name` field. Normalize to `list[str]` so callers can iterate without
runtime shape checks.
"""
raw = await self._get_with_retry("/location")
if not isinstance(raw, list):
return []
names: list[str] = []
for entry in raw:
if isinstance(entry, str):
names.append(entry)
elif isinstance(entry, dict):
name = entry.get("name")
if isinstance(name, str):
names.append(name)
return names
async def rename_location(self, current_name: str, new_name: str) -> int:
"""Bulk-rename a location string on all Spoolman spools.
Tries the bulk `PATCH /location/{name}` endpoint first. Spoolman
versions older than ~0.16 don't expose it and respond 404/405 — in
that case fall back to iterating every spool currently at
``current_name`` and PATCHing each one's ``location`` field directly.
Returns the number of spools renamed (or 0 if the bulk endpoint
succeeded without enumerating).
"""
from urllib.parse import quote
encoded = quote(current_name, safe="")
client = await self._get_client()
try:
response = await client.patch(
f"{self.api_url}/location/{encoded}",
json={"name": new_name},
)
response.raise_for_status()
return 0
except httpx.HTTPStatusError as exc:
if exc.response.status_code not in (404, 405):
raise
logger.info(
"Spoolman bulk-rename endpoint unavailable (status %d); falling back to per-spool PATCH",
exc.response.status_code,
)
# Per-spool fallback: enumerate every spool currently at the old name
# and PATCH each. Keep going on individual failures so a single
# already-deleted spool doesn't strand the rest at the old name —
# collect errors and re-raise as a single SpoolmanClientError if any
# leftover survives.
spools = await self.get_all_spools(allow_archived=True)
renamed = 0
failures: list[str] = []
for spool in spools:
if (spool.get("location") or "").strip() != current_name:
continue
try:
await self._request_spool(
"PATCH",
spool["id"],
json_body={"location": new_name},
operation="rename-location",
)
renamed += 1
except SpoolmanNotFoundError:
continue
except Exception as exc: # noqa: BLE001 — accumulate and re-raise below
failures.append(f"spool {spool.get('id')}: {exc}")
if failures:
raise SpoolmanClientError(
f"Spoolman rename fallback failed for {len(failures)} spool(s): {'; '.join(failures[:3])}",
status_code=502,
)
return renamed
async def delete_spool(self, spool_id: int) -> None:
"""Delete a spool from Spoolman."""
await self._request_spool("DELETE", spool_id, operation="delete")
+14
View File
@@ -81,6 +81,20 @@ def mfa_encryption_isolation(monkeypatch, tmp_path):
enc_mod._key_source = None
@pytest.fixture(autouse=True)
def reset_spoolman_location_sync_cache():
"""Drop the per-URL Spoolman location-sync TTL cache between tests.
Without this, a test that runs the sync against `http://localhost:7912`
will skip the sync in any later test that uses the same URL within 60
real seconds — test ordering would then leak assertions across runs."""
from backend.app.services.location_service import _spoolman_location_sync_cache_clear
_spoolman_location_sync_cache_clear()
yield
_spoolman_location_sync_cache_clear()
@pytest.fixture(scope="session")
def event_loop():
"""Create an instance of the default event loop for each test session."""
@@ -96,6 +96,7 @@ class TestApiKeyRbacAllowed:
mock_client.base_url = "http://localhost:7912"
mock_client.health_check = AsyncMock(return_value=True)
mock_client.get_all_spools = AsyncMock(return_value=[])
mock_client.get_distinct_locations = AsyncMock(return_value=[])
with patch(
"backend.app.api.routes.spoolman_inventory._get_client",
AsyncMock(return_value=mock_client),
@@ -0,0 +1,174 @@
"""Integration tests for /inventory/locations (#1004)."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.location import Location
from backend.app.services.location_service import assign_location_name
@pytest.mark.asyncio
@pytest.mark.integration
async def test_locations_crud_and_spool_link(async_client: AsyncClient, db_session: AsyncSession):
create_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf A"})
assert create_resp.status_code == 201
loc = create_resp.json()
assert loc["name"] == "Shelf A"
assert loc["spool_count"] == 0
dup_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "shelf a"})
assert dup_resp.status_code == 409
spool_resp = await async_client.post(
"/api/v1/inventory/spools",
json={"material": "PLA", "location_id": loc["id"]},
)
assert spool_resp.status_code == 200
spool = spool_resp.json()
assert spool["location_id"] == loc["id"]
assert spool["storage_location"] == "Shelf A"
list_resp = await async_client.get("/api/v1/inventory/locations")
assert list_resp.status_code == 200
listed = {item["id"]: item for item in list_resp.json()}
assert listed[loc["id"]]["spool_count"] == 1
delete_resp = await async_client.delete(f"/api/v1/inventory/locations/{loc['id']}")
assert delete_resp.status_code == 409
clear_resp = await async_client.patch(
f"/api/v1/inventory/spools/{spool['id']}",
json={"location_id": None},
)
assert clear_resp.status_code == 200
delete_resp2 = await async_client.delete(f"/api/v1/inventory/locations/{loc['id']}")
assert delete_resp2.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_location_updates_spool_count(async_client: AsyncClient):
create_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "Old Name"})
loc = create_resp.json()
await async_client.post(
"/api/v1/inventory/spools",
json={"material": "PLA", "location_id": loc["id"]},
)
list_before = await async_client.get("/api/v1/inventory/locations")
by_id = {item["id"]: item for item in list_before.json()}
assert by_id[loc["id"]]["spool_count"] == 1
rename_resp = await async_client.patch(
f"/api/v1/inventory/locations/{loc['id']}",
json={"name": "New Name"},
)
assert rename_resp.status_code == 200
assert rename_resp.json()["name"] == "New Name"
assert rename_resp.json()["spool_count"] == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_location_collision_returns_409(async_client: AsyncClient):
first = await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf A"})
second = await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf B"})
assert first.status_code == 201
assert second.status_code == 201
collision = await async_client.patch(
f"/api/v1/inventory/locations/{second.json()['id']}",
json={"name": "Shelf A"},
)
assert collision.status_code == 409
assert collision.json()["detail"] == "A location with this name already exists"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_location_duplicate_after_commit_returns_409(async_client: AsyncClient):
"""Second create with the same name_key must return 409, not 500."""
first = await async_client.post("/api/v1/inventory/locations", json={"name": "Race Shelf"})
second = await async_client.post("/api/v1/inventory/locations", json={"name": "race shelf"})
assert first.status_code == 201
assert second.status_code == 409
assert second.json()["detail"] == "A location with this name already exists"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_locations_is_read_only(async_client: AsyncClient, db_session: AsyncSession):
"""GET /locations is a pure read — no catalog rows appear without explicit writes."""
from sqlalchemy import func, select
loc = Location()
assign_location_name(loc, "Local Only")
db_session.add(loc)
await db_session.commit()
before = await db_session.scalar(select(func.count()).select_from(Location))
resp = await async_client.get("/api/v1/inventory/locations")
after = await db_session.scalar(select(func.count()).select_from(Location))
assert resp.status_code == 200
assert len(resp.json()) == 1
assert before == after == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_location_404_on_unknown_id(async_client: AsyncClient):
resp = await async_client.patch(
"/api/v1/inventory/locations/99999",
json={"name": "Ghost"},
)
assert resp.status_code == 404
assert resp.json()["detail"] == "Location not found"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_location_404_on_unknown_id(async_client: AsyncClient):
resp = await async_client.delete("/api/v1/inventory/locations/99999")
assert resp.status_code == 404
assert resp.json()["detail"] == "Location not found"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_locations_routes_require_auth_when_enabled(async_client: AsyncClient):
"""All five /locations endpoints must return 401 when auth is enabled and
no credentials are presented. Mirror of the pattern from
test_queue_start_user_attribution._enable_auth_with_admin — required by
project policy: every permission-gated route gets a fail-closed test on
first ship, no follow-ups (the two CVSS 9.8/9.9 advisories shipped from
this exact gap)."""
await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": "locations1505admin",
"admin_password": "AdminPass1!",
},
)
# GET /locations — read-gated
list_resp = await async_client.get("/api/v1/inventory/locations")
assert list_resp.status_code == 401, list_resp.text
# POST /locations — write-gated
create_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "Locked"})
assert create_resp.status_code == 401, create_resp.text
# PATCH /locations/{id} — write-gated. Use a synthetic id; the auth gate
# runs before the not-found check, so 401 is the correct expectation even
# when the id doesn't exist.
patch_resp = await async_client.patch("/api/v1/inventory/locations/99999", json={"name": "Locked2"})
assert patch_resp.status_code == 401, patch_resp.text
# DELETE /locations/{id} — write-gated
delete_resp = await async_client.delete("/api/v1/inventory/locations/99999")
assert delete_resp.status_code == 401, delete_resp.text
@@ -2997,3 +2997,189 @@ class TestConfigureAmsSlotPersistsKProfile:
assert response.status_code == 200
# MQTT was indeed called
mock_client.extrusion_cali_sel.assert_called_once()
class TestPrinterAccessCodeVisibility:
"""Regression coverage: GET /printers and GET /printers/{id} must NOT
return ``access_code`` to callers without PRINTERS_UPDATE authority.
Holding ``access_code`` lets the caller talk to the printer's MQTT
directly with serial+code, bypassing every PRINTERS_CONTROL /
PRINTERS_FILES / PRINTERS_AMS_RFID check Bambuddy enforces.
Trust matrix encoded here:
- Auth disabled → access_code visible (single-trust mode)
- JWT Admin → access_code visible
- JWT Operator (has *_UPDATE) → access_code visible (VP-card UX)
- JWT Viewer → access_code STRIPPED
- API key with can_read_status → access_code STRIPPED
"""
@pytest.fixture
async def auth_setup(self, async_client: AsyncClient):
await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": "pcadmin",
"admin_password": "AdminPass1!",
},
)
async def _login(username, password):
resp = await async_client.post(
"/api/v1/auth/login",
json={"username": username, "password": password},
)
return resp.json()["access_token"]
admin_token = await _login("pcadmin", "AdminPass1!")
groups = (
await async_client.get(
"/api/v1/groups/",
headers={"Authorization": f"Bearer {admin_token}"},
)
).json()
operators_group = next(g for g in groups if g["name"] == "Operators")
viewers_group = next(g for g in groups if g["name"] == "Viewers")
for username, password, group in (
("pcoperator", "Operpass1!", operators_group["id"]),
("pcviewer", "Viewpass1!", viewers_group["id"]),
):
await async_client.post(
"/api/v1/users/",
headers={"Authorization": f"Bearer {admin_token}"},
json={"username": username, "password": password, "group_ids": [group]},
)
operator_token = await _login("pcoperator", "Operpass1!")
viewer_token = await _login("pcviewer", "Viewpass1!")
return {
"admin_token": admin_token,
"operator_token": operator_token,
"viewer_token": viewer_token,
}
async def _seed_printer_with_known_code(self, async_client: AsyncClient, admin_token: str) -> int:
resp = await async_client.post(
"/api/v1/printers/",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"name": "AC-Visibility",
"serial_number": "00M09AVISIBILITY",
"ip_address": "192.168.42.42",
"access_code": "SECRET-CODE",
"is_active": True,
"model": "X1C",
},
)
assert resp.status_code == 200, resp.text
return resp.json()["id"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_auth_disabled_includes_access_code(self, async_client: AsyncClient, printer_factory):
"""Single-trust mode: behaviour preserved, code is visible."""
printer = await printer_factory(name="AuthOff", access_code="LOCAL-CODE")
list_resp = await async_client.get("/api/v1/printers/")
detail_resp = await async_client.get(f"/api/v1/printers/{printer.id}")
assert list_resp.status_code == 200
assert detail_resp.status_code == 200
match = next(p for p in list_resp.json() if p["id"] == printer.id)
assert match["access_code"] == "LOCAL-CODE"
assert detail_resp.json()["access_code"] == "LOCAL-CODE"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_admin_jwt_includes_access_code(self, async_client: AsyncClient, auth_setup):
printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
list_resp = await async_client.get("/api/v1/printers/", headers=headers)
detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
assert list_resp.status_code == 200
assert detail_resp.status_code == 200
match = next(p for p in list_resp.json() if p["id"] == printer_id)
assert match["access_code"] == "SECRET-CODE"
assert detail_resp.json()["access_code"] == "SECRET-CODE"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_jwt_includes_access_code(self, async_client: AsyncClient, auth_setup):
"""Operators hold PRINTERS_UPDATE (default role) — the VP-card UX
surfaces the target printer's access_code so they can configure
their slicer. The visibility predicate must keep working for them.
"""
printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
headers = {"Authorization": f"Bearer {auth_setup['operator_token']}"}
list_resp = await async_client.get("/api/v1/printers/", headers=headers)
detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
assert list_resp.status_code == 200
assert detail_resp.status_code == 200
match = next(p for p in list_resp.json() if p["id"] == printer_id)
assert match["access_code"] == "SECRET-CODE"
assert detail_resp.json()["access_code"] == "SECRET-CODE"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_viewer_jwt_excludes_access_code(self, async_client: AsyncClient, auth_setup):
"""The fix: Viewers hold PRINTERS_READ but not PRINTERS_UPDATE, and
must NOT be able to read the printer's secret.
"""
printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
headers = {"Authorization": f"Bearer {auth_setup['viewer_token']}"}
list_resp = await async_client.get("/api/v1/printers/", headers=headers)
detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
assert list_resp.status_code == 200
assert detail_resp.status_code == 200
match = next(p for p in list_resp.json() if p["id"] == printer_id)
# Field absent OR null — both are acceptable (no usable secret reaches the wire).
assert "access_code" not in match or match["access_code"] is None
body = detail_resp.json()
assert "access_code" not in body or body["access_code"] is None
# And the rest of the payload still arrives so the UI keeps working.
assert match["name"] == "AC-Visibility"
assert body["name"] == "AC-Visibility"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_api_key_excludes_access_code(self, async_client: AsyncClient, auth_setup, db_session):
"""API keys with can_read_status hold PRINTERS_READ but the predicate
gates on PRINTERS_UPDATE (admin-only / API-key-unmapped). The key
must NOT be able to exfiltrate access_code.
"""
from backend.app.core.auth import generate_api_key
from backend.app.models.api_key import APIKey
printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
full_key, key_hash, key_prefix = generate_api_key()
api_key = APIKey(
name="visibility-key",
key_hash=key_hash,
key_prefix=key_prefix,
can_read_status=True,
enabled=True,
)
db_session.add(api_key)
await db_session.commit()
list_resp = await async_client.get("/api/v1/printers/", headers={"X-API-Key": full_key})
detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers={"X-API-Key": full_key})
assert list_resp.status_code == 200
assert detail_resp.status_code == 200
match = next(p for p in list_resp.json() if p["id"] == printer_id)
assert "access_code" not in match or match["access_code"] is None
body = detail_resp.json()
assert "access_code" not in body or body["access_code"] is None
@@ -74,6 +74,10 @@ def mock_spoolman_client():
# branch override this on the fly.
mock_client.is_filament_shared = AsyncMock(return_value=False)
mock_client.ensure_extra_field = AsyncMock(return_value=True)
# list_spools calls maybe_sync_spoolman_locations which invokes
# get_distinct_locations on the route-resolved client. Empty list keeps the
# mock honest without staging phantom catalog rows.
mock_client.get_distinct_locations = AsyncMock(return_value=[])
with (
patch(
@@ -67,6 +67,7 @@ def mock_spoolman_client():
client.health_check = AsyncMock(return_value=True)
client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOL])
client.get_distinct_locations = AsyncMock(return_value=[])
with patch(
"backend.app.api.routes.spoolman_inventory._get_client",
@@ -69,6 +69,7 @@ def mock_spoolman_client():
# #1457: assign route enumerates spools to clear stale fallback-tag links.
client.get_spools = AsyncMock(return_value=[])
client.merge_spool_extra = AsyncMock(return_value={"id": 0, "extra": {}})
client.get_distinct_locations = AsyncMock(return_value=[])
with patch(
"backend.app.api.routes.spoolman_inventory._get_client",
@@ -0,0 +1,209 @@
"""Regression tests for storage-location migration backfill (#1004).
Legacy installs may have free-text storage_location values that differ only
by case. The backfill must collapse them to one catalog row and stay
idempotent across restarts.
"""
from __future__ import annotations
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.database import run_migrations
@pytest.fixture(autouse=True)
def force_sqlite_dialect(monkeypatch):
from backend.app.core import db_dialect
monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
from backend.app.core import database as database_module
monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
def _register_all_models():
import backend.app.models # noqa: F401
from backend.app.models import ( # noqa: F401
external_link,
location,
print_log,
print_queue,
project_bom,
slot_preset,
spoolman_k_profile,
spoolman_slot_assignment,
virtual_printer,
)
@pytest.fixture
async def engine_with_case_variant_spools():
from backend.app.core.database import Base
_register_all_models()
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.execute(text("DELETE FROM locations"))
await conn.execute(
text(
"""
INSERT INTO spool (
material, storage_location, label_weight, core_weight,
weight_used, weight_used_baseline, weight_locked
)
VALUES ('PLA', 'Drybox 1', 1000, 250, 0, 0, 0),
('PETG', 'DRYBOX 1', 1000, 250, 0, 0, 0)
"""
)
)
yield engine
await engine.dispose()
async def test_backfill_collapses_case_variant_storage_locations(engine_with_case_variant_spools):
async with engine_with_case_variant_spools.begin() as conn:
await run_migrations(conn)
async with engine_with_case_variant_spools.connect() as conn:
loc_rows = (await conn.execute(text("SELECT id, name, name_key FROM locations ORDER BY id"))).all()
spool_rows = (await conn.execute(text("SELECT id, storage_location, location_id FROM spool ORDER BY id"))).all()
assert len(loc_rows) == 1
assert loc_rows[0].name_key == "drybox 1"
location_id = loc_rows[0].id
assert all(row.location_id == location_id for row in spool_rows)
async def test_backfill_is_idempotent_with_existing_locations(engine_with_case_variant_spools):
async with engine_with_case_variant_spools.begin() as conn:
await run_migrations(conn)
async with engine_with_case_variant_spools.begin() as conn:
await run_migrations(conn)
async with engine_with_case_variant_spools.connect() as conn:
loc_count = (await conn.execute(text("SELECT COUNT(*) FROM locations"))).scalar_one()
linked = (await conn.execute(text("SELECT COUNT(*) FROM spool WHERE location_id IS NOT NULL"))).scalar_one()
assert loc_count == 1
assert linked == 2
@pytest.fixture
async def engine_with_null_storage_location():
"""A spool with NULL storage_location must NOT produce a phantom location row
or get linked to anything — it stays NULL on both fields."""
from backend.app.core.database import Base
_register_all_models()
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.execute(text("DELETE FROM locations"))
await conn.execute(
text(
"""
INSERT INTO spool (
material, storage_location, label_weight, core_weight,
weight_used, weight_used_baseline, weight_locked
)
VALUES ('PLA', NULL, 1000, 250, 0, 0, 0),
('PETG', ' ', 1000, 250, 0, 0, 0),
('TPU', 'Real Shelf', 1000, 250, 0, 0, 0)
"""
)
)
yield engine
await engine.dispose()
async def test_backfill_skips_null_and_whitespace_storage_location(
engine_with_null_storage_location,
):
"""NULL / whitespace-only `storage_location` rows must NOT create catalog
rows; only the 'Real Shelf' value gets a location row + spool link."""
async with engine_with_null_storage_location.begin() as conn:
await run_migrations(conn)
async with engine_with_null_storage_location.connect() as conn:
loc_rows = (await conn.execute(text("SELECT name FROM locations"))).all()
unlinked = (
await conn.execute(text("SELECT material FROM spool WHERE location_id IS NULL ORDER BY material"))
).all()
# Only the row with a real storage_location should be in the catalog.
assert [r.name for r in loc_rows] == ["Real Shelf"]
# The NULL and whitespace-only spools stay unlinked (no phantom row).
assert [r.material for r in unlinked] == ["PETG", "PLA"]
@pytest.fixture
async def engine_with_legacy_null_name_key_location():
"""Simulate a legacy install where a `locations` row was manually inserted
BEFORE the name_key column existed. The migration must backfill the
legacy row's name_key BEFORE the dedup INSERT, so the spool-link UPDATE
can join on the new key (#1505 review IMPORTANT 11)."""
from backend.app.core.database import Base
_register_all_models()
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Drop the model-shaped locations table (which has NOT NULL on
# name_key) and recreate it in its pre-migration shape: no name_key
# column at all, mirroring a real upgrade from a Bambuddy version
# that predates this feature. The migration's idempotent ALTER TABLE
# is what adds the column without a NOT NULL constraint, so the
# legacy row can legally have NULL until the new backfill UPDATE
# runs.
await conn.execute(text("DROP TABLE locations"))
await conn.execute(
text(
"""
CREATE TABLE locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL UNIQUE,
identifier VARCHAR(100),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
await conn.execute(text("INSERT INTO locations (name) VALUES ('Drybox 1')"))
await conn.execute(
text(
"""
INSERT INTO spool (
material, storage_location, label_weight, core_weight,
weight_used, weight_used_baseline, weight_locked
)
VALUES ('PLA', 'Drybox 1', 1000, 250, 0, 0, 0)
"""
)
)
yield engine
await engine.dispose()
async def test_backfill_links_spool_to_legacy_null_name_key_location(
engine_with_legacy_null_name_key_location,
):
async with engine_with_legacy_null_name_key_location.begin() as conn:
await run_migrations(conn)
async with engine_with_legacy_null_name_key_location.connect() as conn:
loc_rows = (await conn.execute(text("SELECT id, name, name_key FROM locations"))).all()
spool_rows = (await conn.execute(text("SELECT location_id FROM spool"))).all()
# Exactly one location row (the pre-existing legacy one); its name_key
# got backfilled by the FIRST step of the migration.
assert len(loc_rows) == 1
assert loc_rows[0].name_key == "drybox 1"
# The spool got linked to that legacy row — under the old ordering it
# would have been left with `location_id IS NULL`.
assert spool_rows[0].location_id == loc_rows[0].id
+219
View File
@@ -0,0 +1,219 @@
"""Unit tests for storage location service (#1004)."""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.location import Location
from backend.app.models.spool import Spool
from backend.app.services.location_service import (
assign_location_name,
enrich_spool_dicts_with_location_id,
get_location_by_name,
location_name_key,
prepare_internal_spool_payload,
rename_location,
resolve_location_by_name,
resolve_spool_location_fields,
sync_locations_from_spoolman,
)
@pytest.mark.asyncio
async def test_resolve_location_by_name_creates(db_session: AsyncSession):
loc = await resolve_location_by_name(db_session, "Shelf A")
await db_session.commit()
assert loc is not None
assert loc.name == "Shelf A"
assert loc.name_key == location_name_key("Shelf A")
again = await get_location_by_name(db_session, "shelf a")
assert again is not None
assert again.id == loc.id
@pytest.mark.asyncio
async def test_prepare_internal_spool_payload_from_location_id(db_session: AsyncSession):
loc = Location()
assign_location_name(loc, "Drawer 2")
db_session.add(loc)
await db_session.commit()
await db_session.refresh(loc)
payload = await prepare_internal_spool_payload(
db_session,
{"material": "PLA", "location_id": loc.id},
{"material", "location_id"},
)
assert payload["location_id"] == loc.id
assert payload["storage_location"] == "Drawer 2"
@pytest.mark.asyncio
async def test_resolve_spool_location_fields_prefers_location_id(db_session: AsyncSession):
loc = Location()
assign_location_name(loc, "Catalog A")
db_session.add(loc)
await db_session.commit()
await db_session.refresh(loc)
resolved = await resolve_spool_location_fields(
db_session,
location_id=loc.id,
storage_location="Other",
fields_set={"location_id", "storage_location"},
)
assert resolved is not None
assert resolved.location_id == loc.id
assert resolved.storage_location == "Catalog A"
@pytest.mark.asyncio
async def test_rename_location_updates_spool_storage(db_session: AsyncSession):
loc = Location()
assign_location_name(loc, "Old Shelf")
spool = Spool(material="PLA", location_id=None, storage_location="Old Shelf")
db_session.add(loc)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(loc)
await rename_location(db_session, loc, "New Shelf")
await db_session.commit()
await db_session.refresh(spool)
assert loc.name == "New Shelf"
assert loc.name_key == location_name_key("New Shelf")
assert spool.storage_location == "New Shelf"
assert spool.location_id == loc.id
@pytest.mark.asyncio
async def test_enrich_spool_dicts_with_location_id(db_session: AsyncSession):
loc = Location()
assign_location_name(loc, "Garage")
db_session.add(loc)
await db_session.commit()
spools = [{"id": 1, "storage_location": "Garage"}, {"id": 2, "storage_location": None}]
await enrich_spool_dicts_with_location_id(db_session, spools)
assert spools[0]["location_id"] == loc.id
assert spools[1]["location_id"] is None
@pytest.mark.asyncio
async def test_sync_locations_from_spoolman_stages_without_commit(db_session: AsyncSession):
class FakeClient:
async def get_distinct_locations(self):
return ["Spoolman Shelf"]
changed = await sync_locations_from_spoolman(db_session, FakeClient())
assert changed is True
loc = await get_location_by_name(db_session, "Spoolman Shelf")
assert loc is not None
# Caller owns the transaction — no commit() was called in sync itself.
assert loc.id is not None
@pytest.mark.asyncio
async def test_sync_locations_from_spoolman_dedupes_case_variants(db_session: AsyncSession):
class FakeClient:
async def get_distinct_locations(self):
return ["Drybox 1", "DRYBOX 1", "Locker"]
changed = await sync_locations_from_spoolman(db_session, FakeClient())
assert changed is True
await db_session.commit()
drybox = await get_location_by_name(db_session, "Drybox 1")
locker = await get_location_by_name(db_session, "Locker")
assert drybox is not None
assert locker is not None
from sqlalchemy import func, select
from backend.app.models.location import Location
count = await db_session.scalar(select(func.count()).select_from(Location))
assert count == 2
@pytest.mark.asyncio
async def test_rename_location_duplicate_name_raises(db_session: AsyncSession):
first = Location()
assign_location_name(first, "Shelf A")
second = Location()
assign_location_name(second, "Shelf B")
db_session.add_all([first, second])
await db_session.commit()
await db_session.refresh(first)
await db_session.refresh(second)
with pytest.raises(ValueError, match="already exists"):
await rename_location(db_session, second, "Shelf A")
@pytest.mark.asyncio
async def test_rename_location_picks_up_legacy_row_with_trailing_whitespace(db_session: AsyncSession):
"""A legacy spool whose `storage_location` carries trailing whitespace
must still get relinked by the rename cascade — the SQL `TRIM()` strips
the column, so the Python comparison must also strip `old_name`."""
loc = Location()
assign_location_name(loc, "Old Shelf")
# Simulate a legacy row whose name was stored with the same value but
# the column entry has whitespace padding (this happens in old free-text
# data + manual DB edits).
legacy_spool = Spool(material="PLA", location_id=None, storage_location=" Old Shelf ")
db_session.add(loc)
db_session.add(legacy_spool)
await db_session.commit()
await db_session.refresh(loc)
await db_session.refresh(legacy_spool)
# Force the in-memory name to carry trailing whitespace so the rename
# path lifts a non-stripped `old_name`. This is the asymmetry the fix
# addresses (#1505 review IMPORTANT 10).
loc.name = "Old Shelf "
await rename_location(db_session, loc, "New Shelf")
await db_session.commit()
await db_session.refresh(legacy_spool)
assert legacy_spool.storage_location == "New Shelf"
assert legacy_spool.location_id == loc.id
@pytest.mark.asyncio
async def test_sync_locations_from_spoolman_logs_and_returns_false_on_unavailable(db_session: AsyncSession, caplog):
"""Bare `except Exception: return False` was the prior shape — verify the
narrowed catch surfaces a warning so ops can see Spoolman outages."""
from backend.app.services.spoolman import SpoolmanUnavailableError
class FailingClient:
async def get_distinct_locations(self):
raise SpoolmanUnavailableError("Cannot reach Spoolman")
with caplog.at_level("WARNING", logger="backend.app.services.location_service"):
changed = await sync_locations_from_spoolman(db_session, FailingClient())
assert changed is False
assert any("location sync from Spoolman failed" in rec.message for rec in caplog.records)
@pytest.mark.asyncio
async def test_sync_locations_from_spoolman_handles_dict_payload(db_session: AsyncSession):
"""Newer Spoolman returns `list[dict]` from `/location`; the SpoolmanClient
normalises to `list[str]`, so sync_locations_from_spoolman should accept
both shapes via the client contract."""
class DictShapeClient:
async def get_distinct_locations(self):
# SpoolmanClient.get_distinct_locations is the one that normalises;
# at this layer the contract is `list[str]`. Simulate post-normalisation.
return ["Cabinet 3", "Cabinet 3"] # dedup tested elsewhere — sanity here
changed = await sync_locations_from_spoolman(db_session, DictShapeClient())
assert changed is True
await db_session.commit()
cabinet = await get_location_by_name(db_session, "Cabinet 3")
assert cabinet is not None
@@ -0,0 +1,17 @@
import pytest
from pydantic import ValidationError
from backend.app.schemas.settings import AppSettingsUpdate
def test_default_sidebar_order_accepts_hidden_system_item_ids():
value = '{"order":["printers","ext-1","settings"],"hiddenSystemItemIds":["stats"]}'
update = AppSettingsUpdate(default_sidebar_order=value)
assert update.default_sidebar_order == value
def test_default_sidebar_order_rejects_invalid_hidden_system_item_ids():
with pytest.raises(ValidationError):
AppSettingsUpdate(default_sidebar_order='{"order":["printers"],"hiddenSystemItemIds":"stats"}')
@@ -529,3 +529,139 @@ class TestGetExternalFilamentsRaisesOnError:
pytest.raises(SpoolmanUnavailableError),
):
await client.get_external_filaments()
# ---------------------------------------------------------------------------
# get_distinct_locations — shape normalisation (#1505 review BLOCKER 3)
# ---------------------------------------------------------------------------
class TestGetDistinctLocationsShape:
@pytest.mark.asyncio
async def test_passes_through_list_of_strings(self, client):
with patch.object(client, "_get_with_retry", AsyncMock(return_value=["Drybox 1", "Shelf"])):
result = await client.get_distinct_locations()
assert result == ["Drybox 1", "Shelf"]
@pytest.mark.asyncio
async def test_extracts_name_from_list_of_dicts(self, client):
with patch.object(
client,
"_get_with_retry",
AsyncMock(return_value=[{"id": 1, "name": "Drybox 1"}, {"id": 2, "name": "Shelf"}]),
):
result = await client.get_distinct_locations()
assert result == ["Drybox 1", "Shelf"]
@pytest.mark.asyncio
async def test_drops_non_string_and_dict_without_name(self, client):
with patch.object(
client,
"_get_with_retry",
AsyncMock(return_value=[{"id": 1}, None, 42, "Shelf"]),
):
result = await client.get_distinct_locations()
assert result == ["Shelf"]
@pytest.mark.asyncio
async def test_returns_empty_list_on_non_list_payload(self, client):
# A misconfigured proxy or auth-redirect can serve HTML; the old shape
# would TypeError on iteration. We coerce to [].
with patch.object(client, "_get_with_retry", AsyncMock(return_value={"error": "unauthorized"})):
result = await client.get_distinct_locations()
assert result == []
# ---------------------------------------------------------------------------
# rename_location — bulk endpoint + per-spool fallback (#1505 review BLOCKER 2)
# ---------------------------------------------------------------------------
class TestRenameLocationBulkAndFallback:
@pytest.mark.asyncio
async def test_bulk_endpoint_success_returns_zero(self, client):
"""Modern Spoolman PATCH /location/{name} succeeds — fallback not used."""
mock_http = AsyncMock()
mock_http.patch = AsyncMock(return_value=_make_response(None))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
result = await client.rename_location("Drybox 1", "Drybox 2")
assert result == 0
# Confirm the bulk path was used (no per-spool PATCH).
mock_http.patch.assert_called_once()
assert "/location/" in mock_http.patch.call_args.args[0]
@pytest.mark.asyncio
async def test_bulk_endpoint_404_falls_back_to_per_spool_patch(self, client):
"""Older Spoolman versions return 404 on the bulk endpoint — the
fallback iterates every spool currently at the old name."""
bulk_response = MagicMock()
bulk_response.status_code = 404
bulk_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError("404 Not Found", request=MagicMock(), response=MagicMock(status_code=404))
)
mock_http = AsyncMock()
mock_http.patch = AsyncMock(return_value=bulk_response)
spools_at_old = [
{"id": 11, "location": "Drybox 1"},
{"id": 12, "location": "Drybox 1"},
{"id": 13, "location": "Shelf A"}, # different location — must be skipped
]
patch_response = MagicMock()
patch_response.status_code = 200
patch_response.raise_for_status = MagicMock()
patch_response.json.return_value = {"id": 0, "location": "Drybox 2"}
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
patch.object(client, "get_all_spools", AsyncMock(return_value=spools_at_old)),
patch.object(client, "_request_spool", AsyncMock(return_value=patch_response)) as request_spool_mock,
):
result = await client.rename_location("Drybox 1", "Drybox 2")
assert result == 2
# Only the two matching spools should be PATCHed.
assert request_spool_mock.await_count == 2
called_ids = sorted(call.args[1] for call in request_spool_mock.await_args_list)
assert called_ids == [11, 12]
# And each call should set the new location string.
for call in request_spool_mock.await_args_list:
assert call.kwargs["json_body"] == {"location": "Drybox 2"}
@pytest.mark.asyncio
async def test_bulk_endpoint_405_also_falls_back(self, client):
"""Some Spoolman versions return 405 Method Not Allowed instead of 404
when the bulk endpoint is missing — same fallback."""
bulk_response = MagicMock()
bulk_response.status_code = 405
bulk_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError(
"405 Method Not Allowed", request=MagicMock(), response=MagicMock(status_code=405)
)
)
mock_http = AsyncMock()
mock_http.patch = AsyncMock(return_value=bulk_response)
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
patch.object(client, "get_all_spools", AsyncMock(return_value=[])),
):
result = await client.rename_location("Drybox 1", "Drybox 2")
# No spools at the old name → nothing to do, fallback returns 0.
assert result == 0
@pytest.mark.asyncio
async def test_bulk_endpoint_non_404_5xx_propagates(self, client):
"""A genuine server error must NOT silently fall back."""
bulk_response = MagicMock()
bulk_response.status_code = 500
bulk_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError("500", request=MagicMock(), response=MagicMock(status_code=500))
)
mock_http = AsyncMock()
mock_http.patch = AsyncMock(return_value=bulk_response)
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(httpx.HTTPStatusError),
):
await client.rename_location("Drybox 1", "Drybox 2")
Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+44
View File
@@ -0,0 +1,44 @@
# Storage Locations (#1004)
Structured storage locations let you manage physical shelves, drawers, and dryboxes as a catalog instead of free-text only.
## Architecture
- **`locations` table** — catalog of named storage spots (`name` + case-insensitive `name_key`).
- **`spool.location_id`** — source of truth for structured assignment.
- **`spool.storage_location`** — denormalized display string and Spoolman wire format; always derived on write via `location_service.resolve_spool_location_fields()`.
- **Frontend** — spool form sends only `location_id`; backend fills `storage_location`.
## Location vs Storage Location vs AMS Location
| UI label | Meaning |
|----------|---------|
| **Location** (inventory table column) | AMS slot or printer assignment (e.g. `H2D-1 B4`) |
| **Storage Location** | Physical shelf/drawer where the spool lives when not in AMS |
| **Locations page** | Catalog of named storage spots with spool counts |
## Managing locations
1. Open **Inventory → Locations**
2. Click **Add Location** and enter a name (e.g. `Regal Etage 2`)
3. Assign spools via the spool edit form **Storage Location** dropdown
4. Click a location row to filter inventory by that shelf
## Spoolman mode
Bambuddy keeps a local location catalog. When Spoolman integration is enabled:
- Assigning a location writes the location **name** to Spoolman's `location` field
- Listing locations syncs distinct names from Spoolman into the catalog
- Renaming a location bulk-renames spools in Spoolman via `PATCH /location/{old}`
## Upgrade migration
Existing free-text `storage_location` values are automatically imported into the location catalog and linked on upgrade (case-insensitive dedup via `name_key`).
## Testing before release
1. `./test_frontend.sh` — i18n parity, lint, Vitest
2. `./test_backend.sh` — Ruff, pytest (includes `test_locations_api.py`, `test_location_service.py`)
3. Manual: assign a spool to a location → open **Locations** → spool count updates without reload
4. Companion PR in [bambuddy-wiki](https://github.com/maziggy/bambuddy-wiki) (user-facing guide)
@@ -2,15 +2,21 @@
* Tests for the Layout component.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { waitFor } from '@testing-library/react';
import { render } from '../utils';
import { Layout } from '../../components/Layout';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
describe('Layout', () => {
beforeEach(() => {
vi.mocked(localStorage.getItem).mockReset();
vi.mocked(localStorage.setItem).mockReset();
vi.mocked(localStorage.removeItem).mockReset();
vi.mocked(localStorage.clear).mockReset();
localStorage.clear();
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json([
@@ -102,6 +108,55 @@ describe('Layout', () => {
expect(settingsLink).toBeInTheDocument();
});
});
it('hides system nav items stored in sidebar layout preferences', async () => {
vi.mocked(localStorage.getItem).mockImplementation((key) => {
if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']);
return null;
});
render(<Layout />);
await waitFor(() => {
const sidebar = document.querySelector('aside');
expect(sidebar).toBeInTheDocument();
expect(sidebar?.querySelector('a[href="/inventory"]')).toBeInTheDocument();
});
expect(document.querySelector('aside a[href="/"]')).toBeNull();
});
it('applies admin default sidebar hidden state with the default order', async () => {
const storage: Record<string, string> = {};
vi.mocked(localStorage.getItem).mockImplementation((key) => storage[key] ?? null);
vi.mocked(localStorage.setItem).mockImplementation((key, value) => {
storage[key] = value;
});
server.use(
http.get('/api/v1/settings/default-sidebar-order', () =>
HttpResponse.json({
default_sidebar_order: JSON.stringify({
order: ['inventory', 'printers', 'settings'],
hiddenSystemItemIds: ['printers'],
}),
}),
),
);
render(<Layout />);
await waitFor(() => {
const sidebar = document.querySelector('aside');
expect(sidebar).toBeInTheDocument();
expect(sidebar?.querySelector('a[href="/inventory"]')).toBeInTheDocument();
});
await waitFor(() => {
expect(document.querySelector('aside a[href="/"]')).toBeNull();
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_ORDER_KEY, JSON.stringify(['inventory', 'printers', 'settings']));
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers']));
});
});
});
describe('version display', () => {
@@ -0,0 +1,243 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LocationsModal } from '../../components/LocationsModal';
import { api, ApiError } from '../../api/client';
const mockShowToast = vi.fn();
const mockOnClose = vi.fn();
const mockOnPickLocation = vi.fn();
vi.mock('../../api/client', () => ({
api: {
getLocations: vi.fn(),
createLocation: vi.fn(),
updateLocation: vi.fn(),
deleteLocation: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
},
}));
vi.mock('../../contexts/ToastContext', () => ({
useToast: () => ({ showToast: mockShowToast }),
}));
const locations = [
{ id: 1, name: 'Shelf A', identifier: null, spool_count: 2, created_at: '2026-01-01', updated_at: '2026-01-01' },
{ id: 2, name: 'Drawer 1', identifier: null, spool_count: 0, created_at: '2026-01-01', updated_at: '2026-01-01' },
];
function renderModal(open = true) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<LocationsModal open={open} onClose={mockOnClose} onPickLocation={mockOnPickLocation} />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('LocationsModal', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getLocations).mockResolvedValue(locations);
});
it('renders nothing when open=false', () => {
const { container } = renderModal(false);
expect(container.firstChild).toBeNull();
expect(api.getLocations).not.toHaveBeenCalled();
});
it('renders locations from API when open', async () => {
renderModal();
expect(await screen.findByText('Shelf A')).toBeInTheDocument();
expect(screen.getByText('Drawer 1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
});
it('renders empty state when API returns no locations', async () => {
vi.mocked(api.getLocations).mockResolvedValue([]);
renderModal();
expect(await screen.findByText(/locations\.empty|no storage locations/i)).toBeInTheDocument();
});
it('opens create editor and calls createLocation on submit', async () => {
vi.mocked(api.createLocation).mockResolvedValue({
id: 3,
name: 'Garage',
identifier: null,
spool_count: 0,
created_at: '2026-01-01',
updated_at: '2026-01-01',
});
const user = userEvent.setup();
renderModal();
await screen.findByText('Shelf A');
await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
const input = screen.getByLabelText(/name|locations\.name/i);
await user.type(input, 'Garage');
await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
await waitFor(() => {
expect(api.createLocation).toHaveBeenCalledWith({ name: 'Garage' });
});
expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/created|locations\.created/i), 'success');
});
it('submits create form on Enter key', async () => {
vi.mocked(api.createLocation).mockResolvedValue({
id: 3,
name: 'Garage',
identifier: null,
spool_count: 0,
created_at: '2026-01-01',
updated_at: '2026-01-01',
});
const user = userEvent.setup();
renderModal();
await screen.findByText('Shelf A');
await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
const input = screen.getByLabelText(/name|locations\.name/i);
await user.type(input, 'Garage{Enter}');
await waitFor(() => {
expect(api.createLocation).toHaveBeenCalledWith({ name: 'Garage' });
});
});
it('Escape closes the inner editor first, then the outer modal', async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText('Shelf A');
// Open the inner editor; both dialogs are now in the DOM.
await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
expect(screen.getAllByRole('dialog')).toHaveLength(2);
// First Escape closes the editor only.
await user.keyboard('{Escape}');
await waitFor(() => {
expect(screen.getAllByRole('dialog')).toHaveLength(1);
});
expect(mockOnClose).not.toHaveBeenCalled();
// Second Escape closes the outer modal.
await user.keyboard('{Escape}');
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
it('edits a location and calls updateLocation', async () => {
vi.mocked(api.updateLocation).mockResolvedValue({
id: 2,
name: 'Drawer 2',
identifier: null,
spool_count: 0,
created_at: '2026-01-01',
updated_at: '2026-01-01',
});
const user = userEvent.setup();
renderModal();
await screen.findByText('Drawer 1');
const editButtons = screen.getAllByTitle(/edit|common\.edit/i);
await user.click(editButtons[1]);
const input = screen.getByLabelText(/name|locations\.name/i);
await user.clear(input);
await user.type(input, 'Drawer 2');
await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
await waitFor(() => {
expect(api.updateLocation).toHaveBeenCalledWith(2, { name: 'Drawer 2' });
});
expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/updated|locations\.updated/i), 'success');
});
it('deletes an empty location after confirmation', async () => {
vi.mocked(api.deleteLocation).mockResolvedValue({ status: 'deleted' });
const user = userEvent.setup();
renderModal();
await screen.findByText('Drawer 1');
const row = screen.getByText('Drawer 1').closest('tr');
expect(row).not.toBeNull();
await user.click(within(row!).getByTitle(/^Delete$/i));
await user.click(screen.getAllByRole('button', { name: /^Delete$/i }).pop()!);
await waitFor(() => {
expect(api.deleteLocation).toHaveBeenCalledWith(2);
});
expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/deleted|locations\.deleted/i), 'success');
});
it('blocks delete when spool_count > 0', async () => {
renderModal();
await screen.findByText('Shelf A');
const blockedDelete = screen.getByTitle(/Remove all spools from this location before deleting/i);
expect(blockedDelete).toBeDisabled();
});
it('shows error toast when create returns 409 duplicate name', async () => {
vi.mocked(api.createLocation).mockRejectedValue(
new ApiError('A location with this name already exists', 409),
);
const user = userEvent.setup();
renderModal();
await screen.findByText('Shelf A');
await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
await user.type(screen.getByLabelText(/name|locations\.name/i), 'Shelf A');
await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('A location with this name already exists', 'error');
});
});
it('shows error toast when delete fails', async () => {
vi.mocked(api.deleteLocation).mockRejectedValue(new Error('Delete failed'));
const user = userEvent.setup();
renderModal();
await screen.findByText('Drawer 1');
const row = screen.getByText('Drawer 1').closest('tr');
expect(row).not.toBeNull();
await user.click(within(row!).getByTitle(/^Delete$/i));
await user.click(screen.getAllByRole('button', { name: /^Delete$/i }).pop()!);
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('Delete failed', 'error');
});
});
it('row click calls onPickLocation and onClose', async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText('Shelf A');
const row = screen.getByText('Shelf A').closest('tr')!;
await user.click(row);
expect(mockOnPickLocation).toHaveBeenCalledWith(1);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('shows error toast when rename returns 409 collision', async () => {
vi.mocked(api.updateLocation).mockRejectedValue(
new ApiError('A location with this name already exists', 409),
);
const user = userEvent.setup();
renderModal();
await screen.findByText('Drawer 1');
const editButtons = screen.getAllByTitle(/edit|common\.edit/i);
await user.click(editButtons[1]);
const input = screen.getByLabelText(/name|locations\.name/i);
await user.clear(input);
await user.type(input, 'Shelf A');
await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'A location with this name already exists',
'error',
);
});
});
});
@@ -29,6 +29,7 @@ vi.mock('../../api/client', () => ({
getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
getFilamentPresets: vi.fn().mockResolvedValue([]),
getSpoolCatalog: vi.fn().mockResolvedValue([]),
getLocations: vi.fn().mockResolvedValue([]),
getColorCatalog: vi.fn().mockResolvedValue([]),
getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
getBuiltinFilaments: vi.fn().mockResolvedValue([]),
@@ -21,6 +21,7 @@ vi.mock('../../api/client', () => ({
getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
getFilamentPresets: vi.fn().mockResolvedValue([]),
getSpoolCatalog: vi.fn().mockResolvedValue([]),
getLocations: vi.fn().mockResolvedValue([]),
getColorCatalog: vi.fn().mockResolvedValue([]),
getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
getBuiltinFilaments: vi.fn().mockResolvedValue([]),
@@ -932,20 +933,25 @@ describe('SpoolFormModal — Unassign button (#1336)', () => {
});
});
describe('SpoolFormModal storageLocationTouched', () => {
describe('SpoolFormModal locationIdTouched', () => {
/**
* Regression tests for the round-trip bug: saving the edit modal without
* touching the Storage Location field must NOT include storage_location in
* touching the Storage Location field must NOT include location_id in
* the PATCH payload, so Spoolman's location field is never overwritten with
* a stale cached value.
*/
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getLocations).mockResolvedValue([
{ id: 1, name: 'IKEAREGAL', identifier: null, spool_count: 1, created_at: '', updated_at: '' },
{ id: 2, name: 'Shelf B', identifier: null, spool_count: 0, created_at: '', updated_at: '' },
]);
});
const spoolWithStorageLocation: InventorySpool = {
...existingSpool,
storage_location: 'IKEAREGAL',
location_id: 1,
};
it('excludes storage_location from PATCH when editing without changing it', async () => {
@@ -975,11 +981,12 @@ describe('SpoolFormModal storageLocationTouched', () => {
expect(spoolId).toBe(1);
// storage_location must NOT be in the payload — prevents Spoolman location overwrite
expect(payload).not.toHaveProperty('storage_location');
expect(payload).not.toHaveProperty('location_id');
// Other fields should still be present
expect(payload).toHaveProperty('material', 'PLA');
});
it('includes storage_location in PATCH when editing and changing it', async () => {
it('includes location_id in PATCH when editing and changing it', async () => {
render(
<SpoolFormModal
isOpen={true}
@@ -994,9 +1001,9 @@ describe('SpoolFormModal storageLocationTouched', () => {
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
});
// Find the storage location input and change it
const locationInput = screen.getByPlaceholderText('e.g. Shelf A, Drawer 1');
fireEvent.change(locationInput, { target: { value: 'Shelf B' } });
// Change storage location via the catalog dropdown
const locationSelect = screen.getByLabelText(/storage location/i);
fireEvent.change(locationSelect, { target: { value: '2' } });
const saveButton = screen.getByRole('button', { name: /save/i });
fireEvent.click(saveButton);
@@ -1007,11 +1014,11 @@ describe('SpoolFormModal storageLocationTouched', () => {
const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
expect(spoolId).toBe(1);
// storage_location MUST be present since the user changed it
expect(payload).toHaveProperty('storage_location', 'Shelf B');
expect(payload).toHaveProperty('location_id', 2);
expect(payload).not.toHaveProperty('storage_location');
});
it('includes storage_location when creating a new spool', async () => {
it('includes location_id when creating a new spool', async () => {
render(
<SpoolFormModal
isOpen={true}
@@ -1035,8 +1042,8 @@ describe('SpoolFormModal storageLocationTouched', () => {
});
const [payload] = vi.mocked(api.createSpool).mock.calls[0];
// storage_location MUST be included for new spools (default empty string → null)
expect(payload).toHaveProperty('storage_location', null);
expect(payload).toHaveProperty('location_id', null);
expect(payload).not.toHaveProperty('storage_location');
});
});
@@ -433,6 +433,42 @@ describe('useWebSocket hook', () => {
vi.unstubAllGlobals();
});
it('invalidates inventory queries on inventory_changed message', async () => {
vi.useFakeTimers();
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = await waitForWs();
act(() => {
ws.open();
});
act(() => {
ws.simulateMessage({ type: 'inventory_changed' });
});
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spoolman-inventory-spools'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-locations'] });
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('handles missing_spool_assignment message without error', async () => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
+1
View File
@@ -466,6 +466,7 @@ export const handlers = [
http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
http.get('/api/v1/inventory/colors', () => HttpResponse.json([])),
http.get('/api/v1/inventory/locations', () => HttpResponse.json([])),
http.get('/api/v1/inventory/spools', () => HttpResponse.json([])),
http.get('/api/v1/library/folders', () => HttpResponse.json([])),
http.get('/api/v1/library/folders/by-archive/:id', () => HttpResponse.json([])),
@@ -141,6 +141,7 @@ function setupCommonHandlers(spoolList: object[]) {
http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])),
http.get('/api/v1/inventory/colors', () => HttpResponse.json([])),
http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])),
http.get('/api/v1/inventory/locations', () => HttpResponse.json([])),
http.get('/api/v1/printers/', () => HttpResponse.json([])),
);
}
@@ -2,13 +2,15 @@
* Tests for the SettingsPage component.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { SettingsPage } from '../../pages/SettingsPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
import { setAuthToken } from '../../api/client';
const mockSettings = {
auto_archive: true,
@@ -41,12 +43,18 @@ describe('SettingsPage', () => {
// switch in one test (e.g. clicking "Workflow") doesn't carry into
// sibling tests that expect to land on the default General tab.
window.history.replaceState({}, '', '/');
vi.mocked(localStorage.getItem).mockReset();
vi.mocked(localStorage.setItem).mockReset();
vi.mocked(localStorage.removeItem).mockReset();
vi.mocked(localStorage.clear).mockReset();
localStorage.clear();
setAuthToken(null);
server.use(
http.get('/api/v1/settings/', () => {
return HttpResponse.json(mockSettings);
}),
http.patch('/api/v1/settings/', async ({ request }) => {
http.put('/api/v1/settings/', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ ...mockSettings, ...body });
}),
@@ -70,6 +78,9 @@ describe('SettingsPage', () => {
}),
http.get('/api/v1/auth/status', () => {
return HttpResponse.json({ auth_enabled: false, requires_setup: false });
}),
http.get('/api/v1/external-links/', () => {
return HttpResponse.json([]);
})
);
});
@@ -172,6 +183,244 @@ describe('SettingsPage', () => {
expect(screen.getByText('Check printer firmware')).toBeInTheDocument();
});
});
it('hides a Bambuddy sidebar page from Sidebar', async () => {
const user = userEvent.setup();
render(<SettingsPage />);
await screen.findByRole('heading', { name: 'Sidebar' });
await screen.findAllByText('Visible in sidebar');
vi.mocked(localStorage.setItem).mockClear();
await user.click((await screen.findAllByLabelText('Hide page'))[0]);
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers']));
expect(screen.getByText('Hidden from sidebar')).toBeInTheDocument();
});
it('shows a previously hidden Bambuddy sidebar page from Sidebar', async () => {
vi.mocked(localStorage.getItem).mockImplementation((key) => {
if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']);
return null;
});
const user = userEvent.setup();
render(<SettingsPage />);
await screen.findByRole('heading', { name: 'Sidebar' });
await screen.findByText('Hidden from sidebar');
vi.mocked(localStorage.setItem).mockClear();
await user.click(await screen.findByLabelText('Show page'));
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
expect(screen.getAllByText('Visible in sidebar').length).toBeGreaterThan(0);
});
it('does not allow Settings to be hidden from Sidebar', async () => {
render(<SettingsPage />);
await screen.findByRole('heading', { name: 'Sidebar' });
await screen.findByText('Required in sidebar');
const settingsVisibilityButton = await screen.findByLabelText('Settings cannot be hidden');
expect(settingsVisibilityButton).toBeDisabled();
expect(screen.getByText('Required in sidebar')).toBeInTheDocument();
});
it('presents external links and Bambuddy pages in saved sidebar order', async () => {
vi.mocked(localStorage.getItem).mockImplementation((key) => {
if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'printers', 'settings']);
return null;
});
server.use(
http.get('/api/v1/external-links/', () =>
HttpResponse.json([
{
id: 7,
name: 'Docs',
url: 'https://docs.example.test',
icon: 'Link',
open_in_new_tab: true,
custom_icon: null,
sort_order: 0,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]),
),
);
render(<SettingsPage />);
await screen.findByRole('heading', { name: 'Sidebar' });
const docs = await screen.findByText('Docs');
const printers = screen.getAllByText('Printers').find(element => element.closest('[draggable="true"]'));
expect(printers).toBeDefined();
expect(docs.compareDocumentPosition(printers) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('saves mixed Sidebar order when items are dragged', async () => {
server.use(
http.get('/api/v1/external-links/', () =>
HttpResponse.json([
{
id: 7,
name: 'Docs',
url: 'https://docs.example.test',
icon: 'Link',
open_in_new_tab: true,
custom_icon: null,
sort_order: 0,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]),
),
);
render(<SettingsPage />);
await screen.findByRole('heading', { name: 'Sidebar' });
const docsRow = (await screen.findByText('Docs')).closest('[draggable="true"]');
const printersRow = screen.getAllByText('Printers')
.find(element => element.closest('[draggable="true"]'))
?.closest('[draggable="true"]');
expect(docsRow).not.toBeNull();
expect(printersRow).not.toBeNull();
vi.mocked(localStorage.setItem).mockClear();
const dataTransfer = {
effectAllowed: '',
dropEffect: '',
setData: vi.fn(),
};
fireEvent.dragStart(docsRow!, { dataTransfer });
fireEvent.dragOver(printersRow!, { dataTransfer });
fireEvent.drop(printersRow!, { dataTransfer });
expect(localStorage.setItem).toHaveBeenCalledWith(
SIDEBAR_ORDER_KEY,
JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings']),
);
});
it('resets Sidebar to all pages first and configured links at the bottom', async () => {
vi.mocked(localStorage.getItem).mockImplementation((key) => {
if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers', 'stats']);
if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'settings', 'printers']);
return null;
});
server.use(
http.get('/api/v1/external-links/', () =>
HttpResponse.json([
{
id: 7,
name: 'Docs',
url: 'https://docs.example.test',
icon: 'Link',
open_in_new_tab: true,
custom_icon: null,
sort_order: 0,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]),
),
);
const user = userEvent.setup();
render(<SettingsPage />);
const heading = await screen.findByRole('heading', { name: 'Sidebar' });
const card = heading.closest('#card-sidebar-links');
expect(card).not.toBeNull();
await screen.findByText('Docs');
vi.mocked(localStorage.setItem).mockClear();
await user.click(within(card as HTMLElement).getByRole('button', { name: /reset/i }));
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
expect(localStorage.setItem).toHaveBeenCalledWith(
SIDEBAR_ORDER_KEY,
JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings', 'ext-7']),
);
const settingsRow = screen.getAllByText('Settings')
.find(element => element.closest('[draggable="true"]'))
?.closest('[draggable="true"]');
const docsRow = screen.getByText('Docs').closest('[draggable="true"]');
expect(settingsRow).not.toBeNull();
expect(docsRow).not.toBeNull();
expect(settingsRow!.compareDocumentPosition(docsRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.queryByText('Hidden from sidebar')).not.toBeInTheDocument();
});
it('sets the current Sidebar order as the backend default for settings admins', async () => {
let defaultSidebarOrderPayload: string | null = null;
vi.mocked(localStorage.getItem).mockImplementation((key) => {
if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['stats']);
return null;
});
server.use(
http.get('/api/v1/auth/status', () =>
HttpResponse.json({ auth_enabled: true, requires_setup: false }),
),
http.get('/api/v1/auth/me', () =>
HttpResponse.json({
id: 1,
username: 'admin',
role: 'admin',
is_active: true,
is_admin: false,
groups: [{ id: 1, name: 'Administrators' }],
permissions: ['settings:update'],
created_at: '2026-01-01T00:00:00Z',
}),
),
http.get('/api/v1/settings/', () =>
HttpResponse.json({ ...mockSettings, default_sidebar_order: '' }),
),
http.put('/api/v1/settings/', async ({ request }) => {
const body = await request.json() as { default_sidebar_order?: string };
defaultSidebarOrderPayload = body.default_sidebar_order ?? null;
return HttpResponse.json({ ...mockSettings, ...body });
}),
);
setAuthToken('test-token');
const user = userEvent.setup();
render(<SettingsPage />);
const heading = await screen.findByRole('heading', { name: 'Sidebar' });
const card = heading.closest('#card-sidebar-links');
expect(card).not.toBeNull();
await user.click(within(card as HTMLElement).getByRole('switch', { name: 'Set Default' }));
await waitFor(() => {
expect(defaultSidebarOrderPayload).not.toBeNull();
});
expect(JSON.parse(defaultSidebarOrderPayload!)).toEqual({
order: [
'printers',
'inventory',
'archives',
'queue',
'projects',
'files',
'makerworld',
'profiles',
'maintenance',
'stats',
'settings',
],
hiddenSystemItemIds: ['stats'],
});
});
});
describe('update CTA per deployment shape', () => {
+22 -1
View File
@@ -308,7 +308,10 @@ export interface Printer {
name: string;
serial_number: string;
ip_address: string;
access_code: string;
// Optional because the backend only returns access_code when the caller has
// PRINTERS_UPDATE — Admin / Operator JWTs or auth-disabled mode. Viewers and
// API keys receive a Printer without this field.
access_code?: string;
model: string | null;
location: string | null; // Group/location name
nozzle_count: number; // 1 or 2, auto-detected from MQTT
@@ -1317,6 +1320,15 @@ export interface SpoolCatalogEntry {
is_default: boolean;
}
export interface StorageLocation {
id: number;
name: string;
identifier: string | null;
spool_count: number;
created_at: string;
updated_at: string;
}
export interface ColorCatalogEntry {
id: number;
manufacturer: string;
@@ -2652,6 +2664,7 @@ export interface InventorySpool {
low_stock_threshold_pct: number | null;
k_profiles?: SpoolKProfile[];
storage_location?: string | null;
location_id?: number | null;
}
export interface SpoolmanBulkCreateResult {
@@ -5084,6 +5097,14 @@ export const api = {
request<{ deleted: number }>('/inventory/catalog/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }),
resetSpoolCatalog: () =>
request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
getLocations: () =>
request<StorageLocation[]>('/inventory/locations'),
createLocation: (data: { name: string; identifier?: string | null }) =>
request<StorageLocation>('/inventory/locations', { method: 'POST', body: JSON.stringify(data) }),
updateLocation: (id: number, data: { name?: string; identifier?: string | null }) =>
request<StorageLocation>(`/inventory/locations/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteLocation: (id: number) =>
request<{ status: string }>(`/inventory/locations/${id}`, { method: 'DELETE' }),
getColorCatalog: () =>
request<ColorCatalogEntry[]>('/inventory/colors'),
getColorNameMap: () =>
+235 -47
View File
@@ -1,30 +1,47 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon } from 'lucide-react';
import { Eye, EyeOff, Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon, RotateCcw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { ExternalLink } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
import { AddExternalLinkModal } from './AddExternalLinkModal';
import { ConfirmModal } from './ConfirmModal';
import { getIconByName } from './IconPicker';
import { defaultNavItems } from './Layout';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import {
getHiddenSidebarSystemItemIds,
getSidebarOrder,
saveHiddenSidebarSystemItemIds,
saveSidebarOrder,
} from '../utils/sidebarLayout';
type SidebarLayoutItem =
| { type: 'system'; id: string; navItem: typeof defaultNavItems[number] }
| { type: 'external'; id: string; link: ExternalLink };
export function ExternalLinksSettings() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { authEnabled, hasPermission } = useAuth();
const { showToast } = useToast();
const [showAddModal, setShowAddModal] = useState(false);
const [editingLink, setEditingLink] = useState<ExternalLink | null>(null);
const [deletingLink, setDeletingLink] = useState<ExternalLink | null>(null);
const [draggedId, setDraggedId] = useState<number | null>(null);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [hiddenSystemItemIds, setHiddenSystemItemIds] = useState<string[]>(getHiddenSidebarSystemItemIds);
const [sidebarOrder, setSidebarOrder] = useState<string[]>(() => getSidebarOrder(defaultNavItems.map(i => i.id)));
// Fetch external links
const { data: links, isLoading } = useQuery({
queryKey: ['external-links'],
queryFn: api.getExternalLinks,
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: (id: number) => api.deleteExternalLink(id),
onSuccess: () => {
@@ -32,41 +49,105 @@ export function ExternalLinksSettings() {
},
});
// Reorder mutation
const reorderMutation = useMutation({
mutationFn: (ids: number[]) => api.reorderExternalLinks(ids),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['external-links'] });
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
enabled: authEnabled && hasPermission('settings:update'),
});
const updateDefaultSidebarMutation = useMutation({
mutationFn: (defaultSidebarOrder: string) => api.updateSettings({ default_sidebar_order: defaultSidebarOrder }),
onSuccess: (_data, defaultSidebarOrder) => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
queryClient.invalidateQueries({ queryKey: ['default-sidebar-order'] });
showToast(
defaultSidebarOrder
? t('settings.sidebarDefaultSet')
: t('settings.sidebarDefaultCleared'),
'success'
);
},
onError: () => {
showToast(t('settings.sidebarDefaultFailed'), 'error');
},
});
const handleDragStart = (e: React.DragEvent, id: number) => {
const layoutItems = useMemo<SidebarLayoutItem[]>(() => {
const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
const externalLinksMap = new Map((links || []).map(link => [`ext-${link.id}`, link]));
const result: SidebarLayoutItem[] = [];
const seen = new Set<string>();
const addItem = (id: string) => {
if (seen.has(id)) return;
const navItem = navItemsMap.get(id);
if (navItem) {
result.push({ type: 'system', id, navItem });
seen.add(id);
return;
}
const link = externalLinksMap.get(id);
if (link) {
result.push({ type: 'external', id, link });
seen.add(id);
}
};
sidebarOrder.forEach(addItem);
defaultNavItems.forEach(item => addItem(item.id));
(links || []).forEach(link => addItem(`ext-${link.id}`));
return result;
}, [links, sidebarOrder]);
const persistSidebarOrder = (order: string[]) => {
setSidebarOrder(order);
saveSidebarOrder(order);
};
const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggedId(id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
};
const handleDragOver = (e: React.DragEvent) => {
const handleDragOver = (e: React.DragEvent, id: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverId(id);
};
const handleDrop = (e: React.DragEvent, targetId: number) => {
const handleDrop = (e: React.DragEvent, targetId: string) => {
e.preventDefault();
if (draggedId === null || draggedId === targetId || !links) return;
if (draggedId === null || draggedId === targetId) {
setDraggedId(null);
setDragOverId(null);
return;
}
const currentIds = links.map((l) => l.id);
const draggedIndex = currentIds.indexOf(draggedId);
const targetIndex = currentIds.indexOf(targetId);
const currentOrder = layoutItems.map(item => item.id);
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) return;
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedId(null);
setDragOverId(null);
return;
}
// Reorder
const newIds = [...currentIds];
newIds.splice(draggedIndex, 1);
newIds.splice(targetIndex, 0, draggedId);
currentOrder.splice(draggedIndex, 1);
currentOrder.splice(targetIndex, 0, draggedId);
persistSidebarOrder(currentOrder);
reorderMutation.mutate(newIds);
setDraggedId(null);
setDragOverId(null);
};
const handleDragEnd = () => {
setDraggedId(null);
setDragOverId(null);
};
const handleDelete = (link: ExternalLink) => {
@@ -80,48 +161,163 @@ export function ExternalLinksSettings() {
}
};
const resetSidebarLayout = () => {
const resetOrder = [
...defaultNavItems.map(item => item.id),
...(links || []).map(link => `ext-${link.id}`),
];
setHiddenSystemItemIds([]);
setSidebarOrder(resetOrder);
saveHiddenSidebarSystemItemIds([]);
saveSidebarOrder(resetOrder);
};
const handleToggleDefaultSidebarOrder = (enabled: boolean) => {
const currentOrder = layoutItems.map(item => item.id);
updateDefaultSidebarMutation.mutate(enabled ? JSON.stringify({
order: currentOrder,
hiddenSystemItemIds,
}) : '');
};
const toggleSystemItemVisibility = (id: string) => {
if (id === 'settings') return;
const isHidden = hiddenSystemItemIds.includes(id);
const nextIds = isHidden
? hiddenSystemItemIds.filter(hiddenId => hiddenId !== id)
: [...hiddenSystemItemIds, id];
setHiddenSystemItemIds(nextIds);
saveHiddenSidebarSystemItemIds(nextIds);
};
const canSetDefaultSidebarOrder = authEnabled && hasPermission('settings:update');
const isDefaultSidebarEnabled = !!settings?.default_sidebar_order;
return (
<>
<Card id="card-sidebar-links">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-y-2 gap-x-3">
<div className="flex items-center gap-2">
<Link2 className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Sidebar Links</h2>
<h2 className="text-lg font-semibold text-white">{t('externalLinks.sidebarLayout')}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{canSetDefaultSidebarOrder && (
<label className="flex items-center gap-2 text-sm text-bambu-gray">
<span>{t('settings.setDefault')}</span>
<Toggle
checked={isDefaultSidebarEnabled}
onChange={handleToggleDefaultSidebarOrder}
disabled={updateDefaultSidebarMutation.isPending}
/>
</label>
)}
<Button variant="secondary" size="sm" onClick={resetSidebarLayout} className="whitespace-nowrap">
<RotateCcw className="w-4 h-4" />
{t('settings.reset')}
</Button>
<Button size="sm" onClick={() => setShowAddModal(true)} className="whitespace-nowrap">
<Plus className="w-4 h-4" />
Add Link
</Button>
</div>
<Button size="sm" onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4" />
Add Link
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-bambu-gray mb-4">
Add external links to the sidebar navigation. Drag to reorder.
{t('externalLinks.sidebarLayoutDescription')}
</p>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : links && links.length > 0 ? (
) : (
<div className="space-y-2">
{links.map((link) => {
{layoutItems.map((item) => {
const isHidden = item.type === 'system' && hiddenSystemItemIds.includes(item.id);
const isSettings = item.id === 'settings';
if (item.type === 'system') {
const Icon = item.navItem.icon;
return (
<div
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e, item.id)}
onDragOver={(e) => handleDragOver(e, item.id)}
onDragLeave={() => setDragOverId(null)}
onDrop={(e) => handleDrop(e, item.id)}
onDragEnd={handleDragEnd}
className={`relative flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
draggedId === item.id ? 'opacity-50' : isHidden ? 'opacity-60' : ''
} ${
dragOverId === item.id && draggedId !== item.id
? 'before:absolute before:left-3 before:right-3 before:top-0 before:h-0.5 before:bg-bambu-green'
: ''
}`}
>
<GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray cursor-grab flex-shrink-0" />
<div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-gray">
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<span className="text-white font-medium truncate block">{t(item.navItem.labelKey)}</span>
<span className="text-sm text-bambu-gray truncate block">
{isSettings
? t('externalLinks.requiredInSidebar')
: isHidden
? t('externalLinks.hiddenFromSidebar')
: t('externalLinks.visibleInSidebar')}
</span>
</div>
<button
onClick={() => toggleSystemItemVisibility(item.id)}
disabled={isSettings}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-bambu-gray"
title={isSettings ? t('externalLinks.settingsCannotBeHidden') : isHidden ? t('externalLinks.showPage') : t('externalLinks.hidePage')}
aria-label={isSettings ? t('externalLinks.settingsCannotBeHidden') : isHidden ? t('externalLinks.showPage') : t('externalLinks.hidePage')}
>
{isHidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
);
}
const link = item.link;
const Icon = getIconByName(link.icon);
return (
<div
key={link.id}
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e, link.id)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, link.id)}
className={`flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
draggedId === link.id ? 'opacity-50' : ''
onDragStart={(e) => handleDragStart(e, item.id)}
onDragOver={(e) => handleDragOver(e, item.id)}
onDragLeave={() => setDragOverId(null)}
onDrop={(e) => handleDrop(e, item.id)}
onDragEnd={handleDragEnd}
className={`relative flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
draggedId === item.id ? 'opacity-50' : ''
} ${
dragOverId === item.id && draggedId !== item.id
? 'before:absolute before:left-3 before:right-3 before:top-0 before:h-0.5 before:bg-bambu-green'
: ''
}`}
>
<GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray cursor-grab flex-shrink-0" />
<div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-gray">
<Icon className="w-4 h-4" />
{link.custom_icon ? (
<img
src={api.getExternalLinkIconUrl(link.id)}
alt=""
className="w-4 h-4"
/>
) : (
<Icon className="w-4 h-4" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@@ -151,17 +347,10 @@ export function ExternalLinksSettings() {
);
})}
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<Link2 className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>{t('externalLinks.noLinksConfigured')}</p>
<p className="text-sm">Click "Add Link" to add one</p>
</div>
)}
</CardContent>
</Card>
{/* Add/Edit Modal */}
{(showAddModal || editingLink) && (
<AddExternalLinkModal
link={editingLink}
@@ -172,7 +361,6 @@ export function ExternalLinksSettings() {
/>
)}
{/* Delete Confirmation Modal */}
{deletingLink && (
<ConfirmModal
title="Delete Link"
+44 -128
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, Globe, type LucideIcon } from 'lucide-react';
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -17,6 +17,14 @@ import { Card, CardHeader, CardContent } from './Card';
import { parseUTCDate } from '../utils/date';
import { Button } from './Button';
import { BugReportBubble } from './BugReportBubble';
import {
getHiddenSidebarSystemItemIds,
getSidebarOrder,
isExternalSidebarItemId,
saveHiddenSidebarSystemItemIds,
saveSidebarOrder,
SIDEBAR_LAYOUT_CHANGED_EVENT,
} from '../utils/sidebarLayout';
interface NavItem {
@@ -37,34 +45,9 @@ export const defaultNavItems: NavItem[] = [
{ id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
{ id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
{ id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
// User-account features: kept adjacent to Settings intentionally
{ id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
];
// Get unified sidebar order from localStorage
function getSidebarOrder(): string[] {
const stored = localStorage.getItem('sidebarOrder');
if (stored) {
try {
return JSON.parse(stored);
} catch {
return defaultNavItems.map(i => i.id);
}
}
return defaultNavItems.map(i => i.id);
}
// Save unified sidebar order to localStorage
function saveSidebarOrder(order: string[]) {
localStorage.setItem('sidebarOrder', JSON.stringify(order));
}
// Check if an ID is an external link
function isExternalLinkId(id: string): boolean {
return id.startsWith('ext-');
}
// Get default view from localStorage
export function getDefaultView(): string {
return localStorage.getItem('defaultView') || '/';
@@ -103,9 +86,9 @@ export function Layout() {
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [showSwitchbar, setShowSwitchbar] = useState(false);
const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const defaultSidebarOrder = useMemo(() => defaultNavItems.map(i => i.id), []);
const [sidebarOrder, setSidebarOrder] = useState<string[]>(() => getSidebarOrder(defaultNavItems.map(i => i.id)));
const [hiddenSystemItemIds, setHiddenSystemItemIds] = useState<string[]>(getHiddenSidebarSystemItemIds);
const hasRedirected = useRef(false);
const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
sessionStorage.getItem('dismissedUpdateVersion')
@@ -151,10 +134,16 @@ export function Layout() {
if (!Array.isArray(orderArr) || orderArr.length === 0) return;
// Filter to valid sidebar item IDs only
const validIds = new Set(defaultNavItems.map(i => i.id));
const filtered = orderArr.filter((id: string) => typeof id === 'string' && (validIds.has(id) || isExternalLinkId(id)));
const filtered = orderArr.filter((id: string) => typeof id === 'string' && (validIds.has(id) || isExternalSidebarItemId(id)));
if (filtered.length > 0) {
setSidebarOrder(filtered);
saveSidebarOrder(filtered);
const hiddenIds = Array.isArray(parsed) ? [] : parsed.hiddenSystemItemIds;
if (Array.isArray(hiddenIds)) {
const filteredHiddenIds = hiddenIds.filter((id: string) => typeof id === 'string' && validIds.has(id) && id !== 'settings');
setHiddenSystemItemIds(filteredHiddenIds);
saveHiddenSidebarSystemItemIds(filteredHiddenIds);
}
localStorage.setItem(appliedKey, '1');
}
} catch (e) {
@@ -162,7 +151,8 @@ export function Layout() {
}
}, [defaultSidebarData?.default_sidebar_order, setSidebarOrder, user, authEnabled]);
// Check advanced auth status for conditional nav items
// Check advanced auth status — the notifications nav item is gated on it
// (rendered only when authEnabled && advanced_auth_enabled && user_notifications_enabled).
const { data: advancedAuthStatus } = useQuery({
queryKey: ['advancedAuthStatus'],
queryFn: api.getAdvancedAuthStatus,
@@ -296,10 +286,14 @@ export function Layout() {
files: ['library:read', 'library:read_own', 'library:read_all'],
makerworld: 'makerworld:view',
settings: 'settings:read',
notifications: 'notifications:user_email',
};
const isHidden = (id: string) => {
// User-toggled hide (#1673) wins first — cheapest check, explicit intent.
if (hiddenSystemItemIds.includes(id)) return true;
// Permission gate accepts Permission | Permission[] so resources with
// granular `*:read_own` / `*:read_all` tiers (default Operators group)
// don't get hidden from users who only hold the granular variant (#1755).
if (authEnabled && id in navPermissions) {
const required = navPermissions[id];
const granted = Array.isArray(required)
@@ -342,58 +336,6 @@ export function Layout() {
return result;
})();
// Unified drag handlers
const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggedId(id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
};
const handleDragOver = (e: React.DragEvent, id: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverId(id);
};
const handleDragLeave = () => {
setDragOverId(null);
};
const handleDrop = (e: React.DragEvent, targetId: string) => {
e.preventDefault();
if (draggedId === null || draggedId === targetId) {
setDraggedId(null);
setDragOverId(null);
return;
}
const currentOrder = [...orderedSidebarIds];
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedId(null);
setDragOverId(null);
return;
}
// Reorder
currentOrder.splice(draggedIndex, 1);
currentOrder.splice(targetIndex, 0, draggedId);
// Save to localStorage and update state
setSidebarOrder(currentOrder);
saveSidebarOrder(currentOrder);
setDraggedId(null);
setDragOverId(null);
};
const handleDragEnd = () => {
setDraggedId(null);
setDragOverId(null);
};
// Show update banner if update available and not dismissed for this version.
// Suppressed when running as a Home Assistant addon — HA Supervisor surfaces
// its own update notification in the HA UI, so the in-app banner is duplicate
@@ -425,6 +367,19 @@ export function Layout() {
localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
}, [sidebarExpanded]);
useEffect(() => {
const refreshSidebarLayout = () => {
setSidebarOrder(getSidebarOrder(defaultSidebarOrder));
setHiddenSystemItemIds(getHiddenSidebarSystemItemIds());
};
window.addEventListener(SIDEBAR_LAYOUT_CHANGED_EVENT, refreshSidebarLayout);
window.addEventListener('storage', refreshSidebarLayout);
return () => {
window.removeEventListener(SIDEBAR_LAYOUT_CHANGED_EVENT, refreshSidebarLayout);
window.removeEventListener('storage', refreshSidebarLayout);
};
}, [defaultSidebarOrder]);
// Close compact drawer on navigation
useEffect(() => {
if (isSidebarCompact) {
@@ -466,7 +421,7 @@ export function Layout() {
const id = orderedSidebarIds[keyNum - 1];
e.preventDefault();
if (isExternalLinkId(id)) {
if (isExternalSidebarItemId(id)) {
// External link
const extLink = extLinksMap.get(id);
if (extLink?.open_in_new_tab) {
@@ -551,7 +506,7 @@ export function Layout() {
<nav className="flex-1 p-2 overflow-y-auto">
<ul className="space-y-2">
{orderedSidebarIds.map((id) => {
const isExternal = isExternalLinkId(id);
const isExternal = isExternalSidebarItemId(id);
if (isExternal) {
// Render external link
@@ -560,22 +515,7 @@ export function Layout() {
const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
return (
<li
key={id}
draggable
onDragStart={(e) => handleDragStart(e, id)}
onDragOver={(e) => handleDragOver(e, id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, id)}
onDragEnd={handleDragEnd}
className={`relative ${
draggedId === id ? 'opacity-50' : ''
} ${
dragOverId === id && draggedId !== id
? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
: ''
}`}
>
<li key={id}>
{link.open_in_new_tab ? (
<a
href={link.url}
@@ -584,9 +524,6 @@ export function Layout() {
className={`flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
>
{sidebarExpanded && !isSidebarCompact && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
{link.custom_icon ? (
<img
src={api.getExternalLinkIconUrl(link.id)}
@@ -610,9 +547,6 @@ export function Layout() {
}
title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
>
{sidebarExpanded && !isSidebarCompact && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
{link.custom_icon ? (
<img
src={api.getExternalLinkIconUrl(link.id)}
@@ -640,22 +574,7 @@ export function Layout() {
const showClearPlateDot = id === 'printers' && needsClearPlate;
return (
<li
key={id}
draggable
onDragStart={(e) => handleDragStart(e, id)}
onDragOver={(e) => handleDragOver(e, id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, id)}
onDragEnd={handleDragEnd}
className={`relative ${
draggedId === id ? 'opacity-50' : ''
} ${
dragOverId === id && draggedId !== id
? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
: ''
}`}
>
<li key={id}>
<NavLink
to={to}
className={({ isActive }) =>
@@ -667,9 +586,6 @@ export function Layout() {
}
title={!isSidebarCompact && !sidebarExpanded ? t(labelKey) : undefined}
>
{sidebarExpanded && !isSidebarCompact && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
<div className="relative">
<Icon className="w-5 h-5 flex-shrink-0" />
{showClearPlateDot && (
@@ -980,7 +896,7 @@ export function Layout() {
<KeyboardShortcutsModal
onClose={() => setShowShortcuts(false)}
sidebarItems={orderedSidebarIds.map(id => {
if (isExternalLinkId(id)) {
if (isExternalSidebarItemId(id)) {
const extLink = extLinksMap.get(id);
return extLink ? { type: 'external' as const, label: extLink.name } : null;
} else {
+274
View File
@@ -0,0 +1,274 @@
import { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { MapPin, Plus, Loader2, Pencil, Trash2, X } from 'lucide-react';
import { api, type StorageLocation } from '../api/client';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
import { useToast } from '../contexts/ToastContext';
import { inventoryLocationsQueryKey, invalidateInventoryLocations } from '../utils/inventoryQueries';
interface LocationsModalProps {
open: boolean;
onClose: () => void;
onPickLocation?: (locationId: number) => void;
}
export function LocationsModal({ open, onClose, onPickLocation }: LocationsModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<StorageLocation | null>(null);
const [name, setName] = useState('');
const [deleteTarget, setDeleteTarget] = useState<StorageLocation | null>(null);
const { data: locations = [], isLoading } = useQuery({
queryKey: inventoryLocationsQueryKey,
queryFn: api.getLocations,
enabled: open,
});
const invalidate = () => {
invalidateInventoryLocations(queryClient);
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
};
const saveMutation = useMutation({
mutationFn: async () => {
const trimmed = name.trim();
if (!trimmed) throw new Error(t('locations.nameRequired'));
if (editing) {
return api.updateLocation(editing.id, { name: trimmed });
}
return api.createLocation({ name: trimmed });
},
onSuccess: () => {
showToast(t(editing ? 'locations.updated' : 'locations.created'), 'success');
setEditorOpen(false);
setEditing(null);
setName('');
invalidate();
},
onError: (err: Error) => {
showToast(err.message || t('locations.saveFailed'), 'error');
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.deleteLocation(id),
onSuccess: () => {
showToast(t('locations.deleted'), 'success');
setDeleteTarget(null);
invalidate();
},
onError: (err: Error) => {
showToast(err.message || t('locations.deleteFailed'), 'error');
},
});
const openCreate = () => {
setEditing(null);
setName('');
setEditorOpen(true);
};
const openEdit = (location: StorageLocation) => {
setEditing(location);
setName(location.name);
setEditorOpen(true);
};
const closeEditor = useCallback(() => {
if (saveMutation.isPending) return;
setEditorOpen(false);
setEditing(null);
setName('');
}, [saveMutation.isPending]);
// Esc closes the inner editor first; if it's closed, Esc closes the outer
// modal — but only when neither save nor delete is mid-flight, so a stray
// keypress during a network round-trip doesn't drop the user back into the
// inventory page with an orphaned spinner.
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (saveMutation.isPending || deleteMutation.isPending) return;
if (editorOpen) {
closeEditor();
} else if (!deleteTarget) {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, editorOpen, deleteTarget, saveMutation.isPending, deleteMutation.isPending, closeEditor, onClose]);
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
saveMutation.mutate();
};
if (!open) return null;
const modalTitleId = 'locations-modal-title';
const editorTitleId = 'location-editor-title';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60"
onClick={() => {
if (saveMutation.isPending || deleteMutation.isPending) return;
onClose();
}}
/>
<div
className="relative w-full max-w-2xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col"
role="dialog"
aria-modal="true"
aria-labelledby={modalTitleId}
>
<div className="flex items-center justify-between gap-4 px-6 py-4 border-b border-bambu-dark-tertiary">
<div>
<h2 id={modalTitleId} className="text-lg font-semibold text-white flex items-center gap-2">
<MapPin className="w-5 h-5 text-bambu-green" />
{t('locations.title')}
</h2>
<p className="text-bambu-gray text-sm mt-0.5">{t('locations.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<Button onClick={openCreate}>
<Plus className="w-4 h-4" />
{t('locations.add')}
</Button>
<button
type="button"
className="p-1.5 text-bambu-gray hover:text-white rounded"
onClick={onClose}
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-16 text-bambu-gray">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
{t('common.loading')}
</div>
) : locations.length === 0 ? (
<div className="py-16 text-center text-bambu-gray">{t('locations.empty')}</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-bambu-dark-tertiary text-left text-bambu-gray">
<th className="px-4 py-3 font-medium">{t('locations.name')}</th>
<th className="px-4 py-3 font-medium text-right">{t('locations.spools')}</th>
<th className="px-4 py-3 font-medium text-right w-32">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{locations.map((loc) => (
<tr
key={loc.id}
className="border-b border-bambu-dark-tertiary/60 hover:bg-bambu-dark-tertiary/30 cursor-pointer"
onClick={() => {
if (onPickLocation) {
onPickLocation(loc.id);
onClose();
}
}}
>
<td className="px-4 py-3 text-white font-medium">{loc.name}</td>
<td className="px-4 py-3 text-right text-bambu-gray">{loc.spool_count}</td>
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
<button
type="button"
className="p-1.5 text-bambu-gray hover:text-bambu-green rounded"
onClick={() => openEdit(loc)}
title={t('common.edit')}
aria-label={t('locations.editAria', { name: loc.name, defaultValue: `Edit ${loc.name}` })}
>
<Pencil className="w-4 h-4" />
</button>
<button
type="button"
className="p-1.5 text-bambu-gray hover:text-red-400 rounded disabled:opacity-40"
disabled={loc.spool_count > 0}
onClick={() => setDeleteTarget(loc)}
title={loc.spool_count > 0 ? t('locations.deleteBlocked') : t('common.delete')}
aria-label={t('locations.deleteAria', { name: loc.name, defaultValue: `Delete ${loc.name}` })}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{editorOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={closeEditor} />
<div
className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-6 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby={editorTitleId}
>
<h3 id={editorTitleId} className="text-lg font-semibold text-white mb-4">
{editing ? t('locations.edit') : t('locations.add')}
</h3>
<form onSubmit={handleSave}>
<label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="location-name">
{t('locations.name')}
</label>
<input
id="location-name"
type="text"
maxLength={255}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green mb-4"
placeholder={t('locations.createPlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={closeEditor}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={saveMutation.isPending || !name.trim()}>
{saveMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{t('common.save')}
</Button>
</div>
</form>
</div>
</div>
)}
{deleteTarget && (
<ConfirmModal
title={t('locations.confirmDelete', { name: deleteTarget.name })}
message={t('locations.confirmDeleteMessage')}
confirmText={t('common.delete')}
variant="danger"
isLoading={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
onCancel={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
+49 -13
View File
@@ -16,6 +16,10 @@ import { AdditionalSection } from './spool-form/AdditionalSection';
import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
import { PAProfileSection } from './spool-form/PAProfileSection';
import { SpoolUsageHistory } from './SpoolUsageHistory';
import {
invalidateInventoryLocations,
invalidateSpoolAndLocationQueries,
} from '../utils/inventoryQueries';
type TabId = 'filament' | 'pa-profile';
@@ -52,6 +56,9 @@ export function SpoolFormModal({
const queryClient = useQueryClient();
const { showToast } = useToast();
const refreshSpoolQueries = () =>
invalidateSpoolAndLocationQueries(queryClient, spoolsQueryKey);
const isEditing = mode === 'edit';
const isCopying = mode === 'copy';
@@ -60,7 +67,7 @@ export function SpoolFormModal({
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
const [activeTab, setActiveTab] = useState<TabId>('filament');
const [weightTouched, setWeightTouched] = useState(false);
const [storageLocationTouched, setStorageLocationTouched] = useState(false);
const [locationIdTouched, setLocationIdTouched] = useState(false);
const [quickAdd, setQuickAdd] = useState(false);
const [quantity, setQuantity] = useState(1);
@@ -72,6 +79,7 @@ export function SpoolFormModal({
// Spool catalog
const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
const [storageLocations, setStorageLocations] = useState<{ id: number; name: string }[]>([]);
// Local presets (OrcaSlicer imports)
const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
@@ -176,6 +184,7 @@ export function SpoolFormModal({
api.getColorCatalog().then(setColorCatalog).catch(console.error);
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
api.getBuiltinFilaments().then(setBuiltinFilaments).catch(console.error);
api.getLocations().then((locs) => setStorageLocations(locs.map((l) => ({ id: l.id, name: l.name })))).catch(console.error);
// Fetch printer calibrations if not provided via props
if (printersWithCalibrations.length === 0) {
@@ -360,7 +369,7 @@ export function SpoolFormModal({
cost_per_kg: spool.cost_per_kg ?? null,
category: spool.category || '',
low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
storage_location: spool.storage_location || '',
location_id: spool.location_id ?? null,
spoolman_filament_id: null,
});
setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
@@ -387,10 +396,21 @@ export function SpoolFormModal({
setErrors({});
setActiveTab('filament');
setWeightTouched(false);
setStorageLocationTouched(false);
setLocationIdTouched(false);
}
}, [isOpen, spool, mode, isCopying]);
// Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
useEffect(() => {
if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
const legacy = spool.storage_location?.trim();
if (!legacy || storageLocations.length === 0) return;
const match = storageLocations.find((l) => l.name.toLowerCase() === legacy.toLowerCase());
if (match) {
setFormData((prev) => (prev.location_id === match.id ? prev : { ...prev, location_id: match.id }));
}
}, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
// Expand all printers in PA profile section when calibrations are available
useEffect(() => {
if (isOpen && resolvedCalibrations.length > 0) {
@@ -412,7 +432,7 @@ export function SpoolFormModal({
: {}),
}));
if (key === 'weight_used') setWeightTouched(true);
if (key === 'storage_location') setStorageLocationTouched(true);
if (key === 'location_id') setLocationIdTouched(true);
if (errors[key]) {
setErrors(prev => ({ ...prev, [key]: undefined }));
}
@@ -456,7 +476,7 @@ export function SpoolFormModal({
const ok = await saveKProfiles(newSpool.id);
if (!ok) return;
}
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
await refreshSpoolQueries();
if (onSpoolsCreated) onSpoolsCreated([newSpool]);
showToast(t('inventory.spoolCreated'), 'success');
onClose();
@@ -495,7 +515,7 @@ export function SpoolFormModal({
await saveKProfiles(s.id);
}
}
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
await refreshSpoolQueries();
if (onSpoolsCreated) onSpoolsCreated(createdSpools);
if (spoolmanResult && spoolmanResult.failed_count > 0) {
showToast(
@@ -529,7 +549,7 @@ export function SpoolFormModal({
const ok = await saveKProfiles(spool.id);
if (!ok) return;
}
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
await refreshSpoolQueries();
showToast(t('inventory.spoolUpdated'), 'success');
onClose();
},
@@ -550,7 +570,7 @@ export function SpoolFormModal({
return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpool>[1]);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
await refreshSpoolQueries();
showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success');
onClose();
},
@@ -748,11 +768,10 @@ export function SpoolFormModal({
data.weight_used = formData.weight_used;
}
// Only send storage_location when creating or when explicitly changed by the user.
// This prevents the modal round-trip from overwriting the Spoolman location field
// with a stale cached value when the user saves without touching this field.
if (!isEditing || storageLocationTouched) {
data.storage_location = formData.storage_location || null;
// Only send location_id when creating or when explicitly changed by the user.
// Backend derives storage_location; omitting on untouched edit avoids stale overwrites.
if (!isEditing || locationIdTouched) {
data.location_id = formData.location_id;
}
if (isEditing) {
@@ -921,6 +940,23 @@ export function SpoolFormModal({
spoolCatalog={spoolCatalog}
currencySymbol={currencySymbol}
availableCategories={availableCategories}
availableLocations={storageLocations}
onCreateLocation={async (name) => {
try {
const created = await api.createLocation({ name });
setStorageLocations((prev) => [...prev, { id: created.id, name: created.name }].sort((a, b) => a.name.localeCompare(b.name)));
await invalidateInventoryLocations(queryClient);
return { id: created.id, name: created.name };
} catch (e) {
// Surface the backend's actual error so the user can
// distinguish 409 duplicate / 400 validation / 500 from
// a generic "save failed" message.
console.error(e);
const message = e instanceof Error ? e.message : t('locations.saveFailed');
showToast(message || t('locations.saveFailed'), 'error');
return null;
}
}}
globalLowStockThreshold={globalLowStockThreshold}
spoolmanMode={spoolmanMode}
/>
@@ -174,6 +174,8 @@ export function AdditionalSection({
spoolCatalog,
currencySymbol,
availableCategories,
availableLocations = [],
onCreateLocation,
globalLowStockThreshold,
spoolmanMode = false,
}: AdditionalSectionProps) {
@@ -183,6 +185,8 @@ export function AdditionalSection({
const [isMeasuredFocused, setIsMeasuredFocused] = useState(false);
const [remainingInput, setRemainingInput] = useState('');
const [isRemainingFocused, setIsRemainingFocused] = useState(false);
const [newLocationName, setNewLocationName] = useState('');
const [creatingLocation, setCreatingLocation] = useState(false);
const remainingWeight = Math.max(0, formData.label_weight - formData.weight_used);
const measuredDefault = formData.core_weight + remainingWeight;
@@ -381,15 +385,61 @@ export function AdditionalSection({
{/* Storage Location */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.storageLocation')}</label>
<input
type="text"
maxLength={255}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.storageLocationPlaceholder')}
value={formData.storage_location}
onChange={(e) => updateField('storage_location', e.target.value)}
/>
<label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-storage-location">
{t('inventory.storageLocation')}
</label>
<select
id="spool-storage-location"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
value={formData.location_id ?? ''}
onChange={(e) => {
const raw = e.target.value;
if (!raw) {
updateField('location_id', null);
return;
}
const id = Number(raw);
updateField('location_id', id);
}}
>
<option value="">{t('inventory.storageLocationNone')}</option>
{availableLocations.map((loc) => (
<option key={loc.id} value={loc.id}>{loc.name}</option>
))}
</select>
{onCreateLocation && (
<div className="mt-2 flex gap-2">
<input
type="text"
maxLength={255}
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('locations.createPlaceholder')}
value={newLocationName}
onChange={(e) => setNewLocationName(e.target.value)}
/>
<button
type="button"
className="px-3 py-2 text-sm rounded-lg bg-bambu-dark-tertiary text-white hover:bg-bambu-gray-dark disabled:opacity-50"
disabled={!newLocationName.trim() || creatingLocation}
onClick={async () => {
const trimmed = newLocationName.trim();
if (!trimmed || !onCreateLocation) return;
setCreatingLocation(true);
try {
const created = await onCreateLocation(trimmed);
if (created) {
updateField('location_id', created.id);
setNewLocationName('');
}
} finally {
setCreatingLocation(false);
}
}}
>
{t('locations.addShort')}
</button>
</div>
)}
</div>
</div>
);
+4 -2
View File
@@ -36,7 +36,7 @@ export interface SpoolFormData {
// User-defined category + per-spool low-stock threshold override (#729).
category: string;
low_stock_threshold_pct: number | null;
storage_location: string;
location_id: number | null;
// When set the spool is linked to a specific Spoolman filament catalog entry;
// the backend skips find_or_create_filament() and uses this ID directly.
spoolman_filament_id: number | null;
@@ -59,7 +59,7 @@ export const defaultFormData: SpoolFormData = {
cost_per_kg: null,
category: '',
low_stock_threshold_pct: null,
storage_location: '',
location_id: null,
spoolman_filament_id: null,
};
@@ -143,6 +143,8 @@ export interface AdditionalSectionProps extends SectionProps {
// Global low-stock threshold (%); shown as placeholder on the per-spool
// override input so users see what they're overriding. #729
globalLowStockThreshold: number;
availableLocations?: { id: number; name: string }[];
onCreateLocation?: (name: string) => Promise<{ id: number; name: string } | null>;
// When true the empty-spool weight is managed by Spoolman on the filament
// object, so SpoolWeightPicker is hidden and an info notice is shown instead.
spoolmanMode?: boolean;
+3
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import { inventoryLocationsQueryKey } from '../utils/inventoryQueries';
interface WebSocketMessage {
type: string;
@@ -288,6 +289,8 @@ export function useWebSocket() {
case 'inventory_changed':
// Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
debouncedInvalidate('inventory-spools');
debouncedInvalidate('spoolman-inventory-spools');
debouncedInvalidate(inventoryLocationsQueryKey[0]);
break;
case 'spool_assignment_changed':
+34 -1
View File
@@ -2186,7 +2186,7 @@ export default {
defaultPrinterDescription: 'Diesen Drucker für Uploads, Nachdrucke und andere Vorgänge vorauswählen.',
slicerBambuStudio: 'Bambu Studio',
slicerOrcaSlicer: 'OrcaSlicer',
sidebarOrderDescription: 'Elemente in der Seitenleiste per Drag & Drop neu anordnen. Hier auf Standardreihenfolge zurücksetzen.',
sidebarOrderDescription: 'Nutze das Seitenleisten-Layout, um Elemente neu anzuordnen, Sichtbarkeit zurückzusetzen und benutzerdefinierte Links zu verwalten.',
setDefault: 'Standard setzen',
sidebarOrderSetDefaultHint: 'Standard setzen übernimmt die aktuelle Menüreihenfolge für Benutzer, die ihre noch nicht angepasst haben.',
sidebarDefaultSet: 'Standard-Menüreihenfolge wurde festgelegt.',
@@ -3690,6 +3690,28 @@ export default {
reportPartialUsageDesc: 'Wenn ein Druck fehlschlägt oder abgebrochen wird, den geschätzten Filamentverbrauch bis zu diesem Zeitpunkt basierend auf dem Schichtfortschritt melden.',
},
locations: {
title: 'Lagerorte',
subtitle: 'Regale, Schubladen und andere physische Lagerplätze für Spulen verwalten',
add: 'Lagerort hinzufügen',
addShort: 'Hinzufügen',
edit: 'Lagerort bearbeiten',
name: 'Name',
spools: 'Spulen',
empty: 'Noch keine Lagerorte. Erstellen Sie Ihr erstes Regal oder Ihre erste Schublade.',
manage: 'Lagerorte',
createPlaceholder: 'z. B. Regal A, Schublade 1',
nameRequired: 'Name des Lagerorts ist erforderlich',
created: 'Lagerort erstellt',
updated: 'Lagerort aktualisiert',
deleted: 'Lagerort gelöscht',
saveFailed: 'Lagerort konnte nicht gespeichert werden',
deleteFailed: 'Lagerort konnte nicht gelöscht werden',
deleteBlocked: 'Entfernen Sie zuerst alle Spulen von diesem Lagerort',
confirmDelete: '„{{name}}“ löschen?',
confirmDeleteMessage: 'Dieser Lagerort wird aus dem Katalog entfernt. Spulen müssen zuerst verschoben werden.',
},
// Inventar
inventory: {
title: 'Spulen-Inventar',
@@ -5118,6 +5140,17 @@ export default {
// External Links
externalLinks: {
title: 'Seitenleisten-Links',
sidebarLayout: 'Seitenleiste',
sidebarLayoutDescription: 'Integrierte Seiten ein- oder ausblenden, externe Links hinzufügen und Elemente ziehen, um die Seitenleisten-Navigation neu zu ordnen.',
systemPages: 'Bambuddy-Seiten',
externalLinks: 'Externe Links',
visibleInSidebar: 'In Seitenleiste sichtbar',
hiddenFromSidebar: 'In Seitenleiste ausgeblendet',
requiredInSidebar: 'In Seitenleiste erforderlich',
hidePage: 'Seite ausblenden',
showPage: 'Seite anzeigen',
settingsCannotBeHidden: 'Einstellungen können nicht ausgeblendet werden',
noLinksConfigured: 'Keine externen Links konfiguriert',
deleteLink: 'Link löschen',
removeCustomIcon: 'Benutzerdefiniertes Symbol entfernen',
+34 -1
View File
@@ -2196,7 +2196,7 @@ export default {
defaultPrinterDescription: 'Pre-select this printer for uploads, reprints, and other operations.',
slicerBambuStudio: 'Bambu Studio',
slicerOrcaSlicer: 'OrcaSlicer',
sidebarOrderDescription: 'Drag items in the sidebar to reorder. Reset to default order here.',
sidebarOrderDescription: 'Use Sidebar to reorder items, reset visibility, and manage custom links.',
setDefault: 'Set Default',
sidebarOrderSetDefaultHint: 'Set default applies the current menu order to users who haven\'t customized theirs.',
sidebarDefaultSet: 'Default menu order has been set.',
@@ -3701,6 +3701,28 @@ export default {
reportPartialUsageDesc: 'When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.',
},
locations: {
title: 'Storage Locations',
subtitle: 'Manage shelves, drawers, and other physical storage spots for your spools',
add: 'Add Location',
addShort: 'Add',
edit: 'Edit Location',
name: 'Name',
spools: 'Spools',
empty: 'No storage locations yet. Create your first shelf or drawer.',
manage: 'Locations',
createPlaceholder: 'e.g. Shelf A, Drawer 1',
nameRequired: 'Location name is required',
created: 'Location created',
updated: 'Location updated',
deleted: 'Location deleted',
saveFailed: 'Failed to save location',
deleteFailed: 'Failed to delete location',
deleteBlocked: 'Remove all spools from this location before deleting',
confirmDelete: 'Delete "{{name}}"?',
confirmDeleteMessage: 'This location will be removed from the catalog. Spools must be moved first.',
},
// Inventory
inventory: {
title: 'Spool Inventory',
@@ -5139,6 +5161,17 @@ export default {
// External Links
externalLinks: {
title: 'Sidebar Links',
sidebarLayout: 'Sidebar',
sidebarLayoutDescription: 'Show or hide built-in pages, add external links, and drag items to reorder the sidebar navigation.',
systemPages: 'Bambuddy pages',
externalLinks: 'External links',
visibleInSidebar: 'Visible in sidebar',
hiddenFromSidebar: 'Hidden from sidebar',
requiredInSidebar: 'Required in sidebar',
hidePage: 'Hide page',
showPage: 'Show page',
settingsCannotBeHidden: 'Settings cannot be hidden',
noLinksConfigured: 'No external links configured',
deleteLink: 'Delete Link',
removeCustomIcon: 'Remove custom icon',
+34 -1
View File
@@ -2189,7 +2189,7 @@ export default {
defaultPrinterDescription: 'Preseleccione esta impresora para subidas, reimpresiones y otras operaciones.',
slicerBambuStudio: 'Bambu Studio',
slicerOrcaSlicer: 'OrcaSlicer',
sidebarOrderDescription: 'Arrastre los elementos de la barra lateral para reordenarlos. Restablezca aquí el orden predeterminado.',
sidebarOrderDescription: 'Use Diseño de la barra lateral para reordenar elementos, restablecer la visibilidad y gestionar enlaces personalizados.',
setDefault: 'Establecer como predeterminado',
sidebarOrderSetDefaultHint: 'Establecer como predeterminado aplica el orden de menú actual a los usuarios que no han personalizado el suyo.',
sidebarDefaultSet: 'Se ha establecido el orden de menú predeterminado.',
@@ -3693,6 +3693,28 @@ export default {
reportPartialUsageDesc: 'Cuando una impresión falla o se cancela, informar del filamento estimado usado hasta ese punto según el progreso de las capas.',
},
locations: {
title: 'Ubicaciones de almacenamiento',
subtitle: 'Gestione estantes, cajones y otros lugares físicos para sus bobinas',
add: 'Añadir ubicación',
addShort: 'Añadir',
edit: 'Editar ubicación',
name: 'Nombre',
spools: 'Bobinas',
empty: 'Aún no hay ubicaciones de almacenamiento. Cree su primer estante o cajón.',
manage: 'Ubicaciones',
createPlaceholder: 'p. ej. Estante A, Cajón 1',
nameRequired: 'El nombre de la ubicación es obligatorio',
created: 'Ubicación creada',
updated: 'Ubicación actualizada',
deleted: 'Ubicación eliminada',
saveFailed: 'No se pudo guardar la ubicación',
deleteFailed: 'No se pudo eliminar la ubicación',
deleteBlocked: 'Retire todas las bobinas de esta ubicación antes de eliminarla',
confirmDelete: '¿Eliminar «{{name}}»?',
confirmDeleteMessage: 'Esta ubicación se eliminará del catálogo. Mueva las bobinas primero.',
},
// Inventory
inventory: {
title: 'Inventario de bobinas',
@@ -5127,6 +5149,17 @@ export default {
// External Links
externalLinks: {
title: 'Enlaces de la barra lateral',
sidebarLayout: 'Barra lateral',
sidebarLayoutDescription: 'Muestra u oculta páginas integradas, añade enlaces externos y arrastra elementos para reordenar la navegación lateral.',
systemPages: 'Páginas de Bambuddy',
externalLinks: 'Enlaces externos',
visibleInSidebar: 'Visible en la barra lateral',
hiddenFromSidebar: 'Oculto en la barra lateral',
requiredInSidebar: 'Obligatorio en la barra lateral',
hidePage: 'Ocultar página',
showPage: 'Mostrar página',
settingsCannotBeHidden: 'Ajustes no se puede ocultar',
noLinksConfigured: 'No hay enlaces externos configurados',
deleteLink: 'Eliminar enlace',
removeCustomIcon: 'Quitar el icono personalizado',
+34 -1
View File
@@ -2142,7 +2142,7 @@ export default {
defaultPrinterDescription: 'Présélectionner cette imprimante pour les téléversements, réimpressions et autres opérations.',
slicerBambuStudio: 'Bambu Studio',
slicerOrcaSlicer: 'OrcaSlicer',
sidebarOrderDescription: 'Glissez les éléments dans la barre latérale pour réorganiser. Réinitialiser l\'ordre par défaut ici.',
sidebarOrderDescription: 'Utilisez la disposition de la barre latérale pour réorganiser les éléments, réinitialiser la visibilité et gérer les liens personnalisés.',
setDefault: 'Définir par défaut',
sidebarOrderSetDefaultHint: 'Définir par défaut applique l\'ordre actuel du menu aux utilisateurs qui n\'ont pas encore personnalisé le leur.',
sidebarDefaultSet: 'L\'ordre du menu par défaut a été défini.',
@@ -3679,6 +3679,28 @@ export default {
reportPartialUsageDesc: 'Si l\'impression échoue, rapporte le filament consommé selon les couches.',
},
locations: {
title: 'Emplacements de stockage',
subtitle: 'Gérez étagères, tiroirs et autres emplacements physiques pour vos bobines',
add: 'Ajouter un emplacement',
addShort: 'Ajouter',
edit: 'Modifier l\'emplacement',
name: 'Nom',
spools: 'Bobines',
empty: 'Aucun emplacement de stockage. Créez votre première étagère ou tiroir.',
manage: 'Emplacements',
createPlaceholder: 'ex. Étagère A, Tiroir 1',
nameRequired: 'Le nom de l\'emplacement est requis',
created: 'Emplacement créé',
updated: 'Emplacement mis à jour',
deleted: 'Emplacement supprimé',
saveFailed: 'Échec de l\'enregistrement de l\'emplacement',
deleteFailed: 'Échec de la suppression de l\'emplacement',
deleteBlocked: 'Retirez d\'abord toutes les bobines de cet emplacement',
confirmDelete: 'Supprimer « {{name}} » ?',
confirmDeleteMessage: 'Cet emplacement sera retiré du catalogue. Déplacez d\'abord les bobines.',
},
// Inventory
inventory: {
title: 'Inventaire de Bobines',
@@ -5108,6 +5130,17 @@ export default {
// External Links
externalLinks: {
title: 'Liens de la barre latérale',
sidebarLayout: 'Barre latérale',
sidebarLayoutDescription: 'Affichez ou masquez les pages intégrées, ajoutez des liens externes et faites glisser les éléments pour réorganiser la navigation latérale.',
systemPages: 'Pages Bambuddy',
externalLinks: 'Liens externes',
visibleInSidebar: 'Visible dans la barre latérale',
hiddenFromSidebar: 'Masqué dans la barre latérale',
requiredInSidebar: 'Obligatoire dans la barre latérale',
hidePage: 'Masquer la page',
showPage: 'Afficher la page',
settingsCannotBeHidden: 'Les paramètres ne peuvent pas être masqués',
noLinksConfigured: 'Aucun lien externe configuré',
deleteLink: 'Supprimer lien',
removeCustomIcon: 'Retirer icône personnalisée',
+34 -1
View File
@@ -2141,7 +2141,7 @@ export default {
defaultPrinterDescription: 'Preseleziona questa stampante per upload, ristampe e altre operazioni.',
slicerBambuStudio: 'Bambu Studio',
slicerOrcaSlicer: 'OrcaSlicer',
sidebarOrderDescription: 'Trascina gli elementi nella barra laterale per riordinare. Ripristina l\'ordine predefinito qui.',
sidebarOrderDescription: 'Usa il layout della barra laterale per riordinare gli elementi, ripristinare la visibilità e gestire i link personalizzati.',
setDefault: 'Imposta predefinito',
sidebarOrderSetDefaultHint: 'Imposta predefinito applica l\'ordine attuale del menu agli utenti che non hanno ancora personalizzato il proprio.',
sidebarDefaultSet: 'L\'ordine predefinito del menu è stato impostato.',
@@ -3678,6 +3678,28 @@ export default {
reportPartialUsageDesc: 'Quando una stampa fallisce o viene annullata, segnala il filamento stimato usato fino a quel punto in base all\'avanzamento layer.',
},
locations: {
title: 'Ubicazioni di stoccaggio',
subtitle: 'Gestisci scaffali, cassetti e altri posti fisici per le bobine',
add: 'Aggiungi ubicazione',
addShort: 'Aggiungi',
edit: 'Modifica ubicazione',
name: 'Nome',
spools: 'Bobine',
empty: 'Nessuna ubicazione di stoccaggio. Crea il tuo primo scaffale o cassetto.',
manage: 'Ubicazioni',
createPlaceholder: 'es. Scaffale A, Cassetto 1',
nameRequired: 'Il nome dell\'ubicazione è obbligatorio',
created: 'Ubicazione creata',
updated: 'Ubicazione aggiornata',
deleted: 'Ubicazione eliminata',
saveFailed: 'Impossibile salvare l\'ubicazione',
deleteFailed: 'Impossibile eliminare l\'ubicazione',
deleteBlocked: 'Rimuovi prima tutte le bobine da questa ubicazione',
confirmDelete: 'Eliminare «{{name}}»?',
confirmDeleteMessage: 'Questa ubicazione verrà rimossa dal catalogo. Sposta prima le bobine.',
},
// Inventory
inventory: {
title: 'Inventario Bobine',
@@ -5107,6 +5129,17 @@ export default {
// External Links
externalLinks: {
title: 'Link della barra laterale',
sidebarLayout: 'Barra laterale',
sidebarLayoutDescription: 'Mostra o nascondi le pagine integrate, aggiungi link esterni e trascina gli elementi per riordinare la navigazione laterale.',
systemPages: 'Pagine Bambuddy',
externalLinks: 'Link esterni',
visibleInSidebar: 'Visibile nella barra laterale',
hiddenFromSidebar: 'Nascosto nella barra laterale',
requiredInSidebar: 'Obbligatorio nella barra laterale',
hidePage: 'Nascondi pagina',
showPage: 'Mostra pagina',
settingsCannotBeHidden: 'Le impostazioni non possono essere nascoste',
noLinksConfigured: 'Nessun link esterno configurato',
deleteLink: 'Elimina link',
removeCustomIcon: 'Rimuovi icona personalizzata',
+34 -1
View File
@@ -2185,7 +2185,7 @@ export default {
defaultPrinterDescription: 'アップロード、再印刷、その他の操作でこのプリンターを事前選択します。',
slicerBambuStudio: 'Bambu Studio',
slicerOrcaSlicer: 'OrcaSlicer',
sidebarOrderDescription: 'サイドバーの項目をドラッグして並べ替え。ここでデフォルトの順序にリセット。',
sidebarOrderDescription: 'サイドバーレイアウトで項目の並べ替え、表示状態のリセット、カスタムリンクの管理を行います。',
setDefault: 'デフォルト設定',
sidebarOrderSetDefaultHint: 'デフォルト設定は、まだカスタマイズしていないユーザーに現在のメニュー順序を適用します。',
sidebarDefaultSet: 'デフォルトメニュー順序を設定しました。',
@@ -3690,6 +3690,28 @@ export default {
reportPartialUsageDesc: '印刷が失敗またはキャンセルされた場合、レイヤー進捗に基づいてその時点までの推定フィラメント使用量を報告します。',
},
locations: {
title: '保管場所',
subtitle: '棚・引き出しなど、スプールの物理的な保管場所を管理',
add: '場所を追加',
addShort: '追加',
edit: '場所を編集',
name: '名前',
spools: 'スプール',
empty: '保管場所がありません。最初の棚または引き出しを作成してください。',
manage: '保管場所',
createPlaceholder: '例: 棚A、引き出し1',
nameRequired: '場所名が必要です',
created: '場所を作成しました',
updated: '場所を更新しました',
deleted: '場所を削除しました',
saveFailed: '場所の保存に失敗しました',
deleteFailed: '場所の削除に失敗しました',
deleteBlocked: '削除前にこの場所のスプールをすべて移動してください',
confirmDelete: '「{{name}}」を削除しますか?',
confirmDeleteMessage: 'この場所はカタログから削除されます。先にスプールを移動してください。',
},
// Inventory
inventory: {
title: 'スプール在庫管理',
@@ -5119,6 +5141,17 @@ export default {
// External Links
externalLinks: {
title: 'サイドバーリンク',
sidebarLayout: 'サイドバー',
sidebarLayoutDescription: '組み込みページの表示/非表示を切り替え、外部リンクを追加し、項目をドラッグしてサイドバーナビゲーションを並べ替えます。',
systemPages: 'Bambuddyページ',
externalLinks: '外部リンク',
visibleInSidebar: 'サイドバーに表示',
hiddenFromSidebar: 'サイドバーで非表示',
requiredInSidebar: 'サイドバーで必須',
hidePage: 'ページを非表示',
showPage: 'ページを表示',
settingsCannotBeHidden: '設定は非表示にできません',
noLinksConfigured: '外部リンクが設定されていません',
deleteLink: 'リンクを削除',
removeCustomIcon: 'カスタムアイコンを削除',
+35 -1
View File
@@ -2058,7 +2058,7 @@ export default {
defaultPrinterDescription: '업로드, 재인쇄 등 작업에서 이 프린터를 미리 선택합니다.',
slicerBambuStudio: 'Bambu Studio',
slicerOrcaSlicer: 'OrcaSlicer',
sidebarOrderDescription: '사이드바의 항목을 드래그하여 순서를 변경하세요. 여기서 기본 순서로 초기화하세요.',
sidebarOrderDescription: '사이드바 레이아웃에서 항목 순서를 변경하고, 표시 상태를 초기화하고, 사용자 지정 링크를 관리하세요.',
setDefault: '기본값 설정',
sidebarOrderSetDefaultHint: '기본값 설정은 현재 메뉴 순서를 사용자 지정하지 않은 사용자에게 적용합니다.',
sidebarDefaultSet: '기본 메뉴 순서가 설정되었습니다.',
@@ -3483,6 +3483,29 @@ export default {
reportPartialUsage: '실패한 인쇄물에 대한 부분 사용량 보고',
reportPartialUsageDesc: '인쇄가 실패하거나 취소될 때 레이어 진행률을 기반으로 해당 시점까지 사용된 예상 필라멘트를 보고합니다.'
},
locations: {
title: '보관 위치',
subtitle: '스풀의 선반, 서랍 등 물리적 보관 장소를 관리합니다',
add: '위치 추가',
addShort: '추가',
edit: '위치 편집',
name: '이름',
spools: '스풀',
empty: '아직 보관 위치가 없습니다. 첫 번째 선반이나 서랍을 만드세요.',
manage: '위치',
createPlaceholder: '예: 선반 A, 서랍 1',
nameRequired: '위치 이름은 필수입니다',
created: '위치가 생성되었습니다',
updated: '위치가 업데이트되었습니다',
deleted: '위치가 삭제되었습니다',
saveFailed: '위치 저장에 실패했습니다',
deleteFailed: '위치 삭제에 실패했습니다',
deleteBlocked: '삭제하기 전에 이 위치의 모든 스풀을 옮기세요',
confirmDelete: '"{{name}}"을(를) 삭제하시겠습니까?',
confirmDeleteMessage: '이 위치가 카탈로그에서 제거됩니다. 스풀을 먼저 옮겨야 합니다.',
},
inventory: {
title: '스풀 재고',
spoolmanMixedContentTitle: 'HTTPS에서 Spoolman을 불러올 수 없음 — 브라우저가 혼합 콘텐츠를 차단함',
@@ -4825,6 +4848,17 @@ export default {
removeLink: '링크 제거'
},
externalLinks: {
title: '사이드바 링크',
sidebarLayout: '사이드바',
sidebarLayoutDescription: '기본 제공 페이지를 표시하거나 숨기고, 외부 링크를 추가하고, 항목을 드래그하여 사이드바 탐색 순서를 변경하세요.',
systemPages: 'Bambuddy 페이지',
externalLinks: '외부 링크',
visibleInSidebar: '사이드바에 표시',
hiddenFromSidebar: '사이드바에서 숨김',
requiredInSidebar: '사이드바에 필수',
hidePage: '페이지 숨기기',
showPage: '페이지 표시',
settingsCannotBeHidden: '설정은 숨길 수 없습니다',
noLinksConfigured: '구성된 외부 링크 없음',
deleteLink: '링크 삭제',
removeCustomIcon: '사용자 지정 아이콘 제거',
+34 -1
View File
@@ -2141,7 +2141,7 @@ export default {
defaultPrinterDescription: 'Pré-selecionar esta impressora para uploads, reimpressões e outras operações.',
slicerBambuStudio: 'Bambu Studio',
slicerOrcaSlicer: 'OrcaSlicer',
sidebarOrderDescription: 'Arraste itens na barra lateral para reordenar. Restaurar ordem padrão aqui.',
sidebarOrderDescription: 'Use o layout da barra lateral para reordenar itens, restaurar a visibilidade e gerenciar links personalizados.',
setDefault: 'Definir padrão',
sidebarOrderSetDefaultHint: 'Definir padrão aplica a ordem atual do menu aos usuários que ainda não personalizaram o seu.',
sidebarDefaultSet: 'Ordem padrão do menu foi definida.',
@@ -3678,6 +3678,28 @@ export default {
reportPartialUsageDesc: 'Quando uma impressão falha ou é cancelada, relate o filamento estimado usado até aquele ponto com base no progresso das camadas.',
},
locations: {
title: 'Locais de armazenamento',
subtitle: 'Gerencie prateleiras, gavetas e outros locais físicos para bobinas',
add: 'Adicionar local',
addShort: 'Adicionar',
edit: 'Editar local',
name: 'Nome',
spools: 'Bobinas',
empty: 'Nenhum local de armazenamento. Crie sua primeira prateleira ou gaveta.',
manage: 'Locais',
createPlaceholder: 'ex. Prateleira A, Gaveta 1',
nameRequired: 'O nome do local é obrigatório',
created: 'Local criado',
updated: 'Local atualizado',
deleted: 'Local excluído',
saveFailed: 'Falha ao salvar local',
deleteFailed: 'Falha ao excluir local',
deleteBlocked: 'Remova todas as bobinas deste local antes de excluir',
confirmDelete: 'Excluir «{{name}}»?',
confirmDeleteMessage: 'Este local será removido do catálogo. Mova as bobinas primeiro.',
},
// Inventory
inventory: {
title: 'Inventário de Carretéis',
@@ -5107,6 +5129,17 @@ export default {
// External Links
externalLinks: {
title: 'Links da barra lateral',
sidebarLayout: 'Barra lateral',
sidebarLayoutDescription: 'Mostre ou oculte páginas integradas, adicione links externos e arraste itens para reordenar a navegação lateral.',
systemPages: 'Páginas do Bambuddy',
externalLinks: 'Links externos',
visibleInSidebar: 'Visível na barra lateral',
hiddenFromSidebar: 'Oculto na barra lateral',
requiredInSidebar: 'Obrigatório na barra lateral',
hidePage: 'Ocultar página',
showPage: 'Mostrar página',
settingsCannotBeHidden: 'Configurações não pode ser ocultado',
noLinksConfigured: 'Nenhum link externo configurado',
deleteLink: 'Excluir link',
removeCustomIcon: 'Remover ícone personalizado',
+34 -1
View File
@@ -2189,7 +2189,7 @@ export default {
defaultPrinterDescription: 'Yüklemeler, tekrar baskılar ve diğer işlemler için bu yazıcıyı önceden seç.',
slicerBambuStudio: 'Bambu Studio',
slicerOrcaSlicer: 'OrcaSlicer',
sidebarOrderDescription: 'Yeniden sıralamak için kenar çubuğundaki öğeleri sürükleyin. Varsayılan sıraya buradan sıfırlayın.',
sidebarOrderDescription: 'Öğeleri yeniden sıralamak, görünürlüğü sıfırlamak ve özel bağlantıları yönetmek için Kenar çubuğu düzenini kullanın.',
setDefault: 'Varsayılan Yap',
sidebarOrderSetDefaultHint: 'Varsayılan ayarla, mevcut menü sırasını henüz özelleştirmemiş kullanıcılara uygular.',
sidebarDefaultSet: 'Varsayılan menü sırası ayarlandı.',
@@ -3679,6 +3679,28 @@ export default {
reportPartialUsageDesc: 'Bir baskı başarısız olduğunda veya iptal edildiğinde, katman ilerlemesine göre o noktaya kadar kullanılan tahmini filamenti bildir.',
},
locations: {
title: 'Depolama Konumları',
subtitle: 'Makaralarınız için raf, çekmece ve diğer fiziksel depolama yerlerini yönetin',
add: 'Konum Ekle',
addShort: 'Ekle',
edit: 'Konumu Düzenle',
name: 'Ad',
spools: 'Makaralar',
empty: 'Henüz depolama konumu yok. İlk rafınızı veya çekmecenizi oluşturun.',
manage: 'Konumlar',
createPlaceholder: 'örn. Raf A, Çekmece 1',
nameRequired: 'Konum adı zorunludur',
created: 'Konum oluşturuldu',
updated: 'Konum güncellendi',
deleted: 'Konum silindi',
saveFailed: 'Konum kaydedilemedi',
deleteFailed: 'Konum silinemedi',
deleteBlocked: 'Silmeden önce bu konumdaki tüm makaraları taşıyın',
confirmDelete: '"{{name}}" silinsin mi?',
confirmDeleteMessage: 'Bu konum kataloğdan kaldırılacak. Önce makaralar taşınmalıdır.',
},
// Envanter
inventory: {
title: 'Makara Envanteri',
@@ -5062,6 +5084,17 @@ export default {
// Harici Bağlantılar
externalLinks: {
title: 'Kenar çubuğu bağlantıları',
sidebarLayout: 'Kenar çubuğu',
sidebarLayoutDescription: 'Yerleşik sayfaları gösterin veya gizleyin, harici bağlantılar ekleyin ve kenar çubuğu gezinmesini yeniden sıralamak için öğeleri sürükleyin.',
systemPages: 'Bambuddy sayfaları',
externalLinks: 'Harici bağlantılar',
visibleInSidebar: 'Kenar çubuğunda görünür',
hiddenFromSidebar: 'Kenar çubuğunda gizli',
requiredInSidebar: 'Kenar çubuğunda gerekli',
hidePage: 'Sayfayı gizle',
showPage: 'Sayfayı göster',
settingsCannotBeHidden: 'Ayarlar gizlenemez',
noLinksConfigured: 'Yapılandırılmış harici bağlantı yok',
deleteLink: 'Bağlantıyı Sil',
removeCustomIcon: 'Özel simgeyi kaldır',
+34 -1
View File
@@ -2186,7 +2186,7 @@ export default {
defaultPrinterDescription: '为上传、重印和其他操作预选此打印机。',
slicerBambuStudio: 'Bambu Studio',
slicerOrcaSlicer: 'OrcaSlicer',
sidebarOrderDescription: '拖拽侧边栏项目以重新排序。在此处重置为默认顺序。',
sidebarOrderDescription: '使用侧边栏布局重新排序项目、重置可见性并管理自定义链接。',
setDefault: '设为默认',
sidebarOrderSetDefaultHint: '设为默认将当前菜单顺序应用于尚未自定义的用户。',
sidebarDefaultSet: '已设置默认菜单顺序。',
@@ -3678,6 +3678,28 @@ export default {
reportPartialUsageDesc: '当打印失败或被取消时,根据层进度报告估计的耗材使用量。',
},
locations: {
title: '存储位置',
subtitle: '管理货架、抽屉等线轴物理存放位置',
add: '添加位置',
addShort: '添加',
edit: '编辑位置',
name: '名称',
spools: '线轴',
empty: '尚无存储位置。创建第一个货架或抽屉。',
manage: '位置',
createPlaceholder: '例如:A 架、抽屉 1',
nameRequired: '位置名称为必填项',
created: '位置已创建',
updated: '位置已更新',
deleted: '位置已删除',
saveFailed: '保存位置失败',
deleteFailed: '删除位置失败',
deleteBlocked: '删除前请移走此位置上的所有线轴',
confirmDelete: '删除「{{name}}」?',
confirmDeleteMessage: '此位置将从目录中移除。请先移走线轴。',
},
// Inventory
inventory: {
title: '耗材库存',
@@ -5106,6 +5128,17 @@ export default {
// External Links
externalLinks: {
title: '侧边栏链接',
sidebarLayout: '侧边栏',
sidebarLayoutDescription: '显示或隐藏内置页面,添加外部链接,并拖动项目以重新排序侧边栏导航。',
systemPages: 'Bambuddy 页面',
externalLinks: '外部链接',
visibleInSidebar: '在侧边栏中显示',
hiddenFromSidebar: '在侧边栏中隐藏',
requiredInSidebar: '侧边栏中必需',
hidePage: '隐藏页面',
showPage: '显示页面',
settingsCannotBeHidden: '设置不能隐藏',
noLinksConfigured: '未配置外部链接',
deleteLink: '删除链接',
removeCustomIcon: '移除自定义图标',
+34 -1
View File
@@ -2186,7 +2186,7 @@ export default {
defaultPrinterDescription: '為上傳、重印和其他操作預選此印表機。',
slicerBambuStudio: 'Bambu Studio',
slicerOrcaSlicer: 'OrcaSlicer',
sidebarOrderDescription: '拖曳側邊欄項目以重新排序。在此處重設為預設順序。',
sidebarOrderDescription: '使用側邊欄版面配置重新排序項目、重設可見性並管理自訂連結。',
setDefault: '設為預設',
sidebarOrderSetDefaultHint: '設為預設將目前選單順序套用於尚未自訂的使用者。',
sidebarDefaultSet: '已設定預設選單順序。',
@@ -3678,6 +3678,28 @@ export default {
reportPartialUsageDesc: '當列印失敗或被取消時,根據層進度報告估計的耗材使用量。',
},
locations: {
title: '儲存位置',
subtitle: '管理貨架、抽屜等線軸實體存放位置',
add: '新增位置',
addShort: '新增',
edit: '編輯位置',
name: '名稱',
spools: '線軸',
empty: '尚無儲存位置。建立第一個貨架或抽屜。',
manage: '位置',
createPlaceholder: '例如:A 架、抽屜 1',
nameRequired: '位置名稱為必填',
created: '位置已建立',
updated: '位置已更新',
deleted: '位置已刪除',
saveFailed: '儲存位置失敗',
deleteFailed: '刪除位置失敗',
deleteBlocked: '刪除前請移走此位置上的所有線軸',
confirmDelete: '刪除「{{name}}」?',
confirmDeleteMessage: '此位置將從目錄中移除。請先移走線軸。',
},
// Inventory
inventory: {
title: '耗材庫存',
@@ -5106,6 +5128,17 @@ export default {
// External Links
externalLinks: {
title: '側邊欄連結',
sidebarLayout: '側邊欄',
sidebarLayoutDescription: '顯示或隱藏內建頁面、加入外部連結,並拖曳項目以重新排序側邊欄導覽。',
systemPages: 'Bambuddy 頁面',
externalLinks: '外部連結',
visibleInSidebar: '顯示於側邊欄',
hiddenFromSidebar: '隱藏於側邊欄',
requiredInSidebar: '側邊欄中必須顯示',
hidePage: '隱藏頁面',
showPage: '顯示頁面',
settingsCannotBeHidden: '設定無法隱藏',
noLinksConfigured: '未設定外部連結',
deleteLink: '刪除連結',
removeCustomIcon: '移除自訂圖示',
+64 -18
View File
@@ -6,7 +6,7 @@ import {
Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser, MapPin,
Upload, Download,
} from 'lucide-react';
import { ForecastPanel } from '../components/ForecastPanel';
@@ -20,6 +20,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
import { LocationsModal } from '../components/LocationsModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { resolveSpoolColorName } from '../utils/colors';
@@ -27,6 +28,10 @@ import { getCurrencySymbol } from '../utils/currency';
import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
import { formatSlotLabel } from '../utils/amsHelpers';
import { filterSpoolsByQuery } from '../utils/inventorySearch';
import {
inventoryLocationsQueryKey,
invalidateSpoolAndLocationQueries,
} from '../utils/inventoryQueries';
import { aggregateGroupSpool } from '../utils/inventoryGrouping';
type ArchiveFilter = 'active' | 'archived';
@@ -478,6 +483,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
// CSV import/export (#1576). Local inventory only — hidden in Spoolman mode.
const [csvImportOpen, setCsvImportOpen] = useState(false);
const [exportingCsv, setExportingCsv] = useState(false);
const [locationsModalOpen, setLocationsModalOpen] = useState(false);
// Filter state
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
@@ -487,10 +493,6 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
const [categoryFilter, setCategoryFilter] = useState('');
const [spoolFilter, setSpoolFilter] = useState('');
const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
// #1400: storage-location dropdown. Uses the sentinel `__none__` for the
// "no storage location set" group, same pattern as the category filter so
// users can find unfiled spools.
const [storageLocationFilter, setStorageLocationFilter] = useState('');
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('table');
const [sortState, setSortState] = useState<SortState>(loadSortState);
@@ -525,6 +527,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
// Query key and fetch function differ based on data source
const spoolsQueryKey = spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'];
const refreshSpoolQueries = () => invalidateSpoolAndLocationQueries(queryClient, spoolsQueryKey);
const { data: spools, isLoading } = useQuery({
queryKey: spoolsQueryKey,
queryFn: () =>
@@ -552,6 +555,20 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
? t('inventory.csv.spoolmanHint', 'In Spoolman mode, use Spoolman\'s built-in CSV import/export.')
: undefined;
const { data: storageLocations = [] } = useQuery({
queryKey: inventoryLocationsQueryKey,
queryFn: api.getLocations,
});
// Deep-link / filter: ?location_id=<id> or ?location_id=__none__
const _rawLocationParam = searchParams.get('location_id');
const storageLocationFilter =
_rawLocationParam === '__none__'
? '__none__'
: _rawLocationParam && /^\d+$/.test(_rawLocationParam) && Number(_rawLocationParam) > 0
? _rawLocationParam
: '';
// Deep-link: open edit modal for ?spool=<id>
// Prefer the already-loaded spool list (no extra API call); fall back to a
// targeted fetch for the rare case where the full list hasn't arrived yet.
@@ -662,7 +679,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
mutationFn: (id: number) =>
spoolmanMode ? api.deleteSpoolmanInventorySpool(id) : api.deleteSpool(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
refreshSpoolQueries();
showToast(t('inventory.spoolDeleted'), 'success');
},
onError: (error: Error) => {
@@ -680,7 +697,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
mutationFn: (id: number) =>
spoolmanMode ? api.archiveSpoolmanInventorySpool(id) : api.archiveSpool(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
refreshSpoolQueries();
showToast(t('inventory.spoolArchived'), 'success');
},
onError: (error: Error) => {
@@ -698,7 +715,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
mutationFn: (id: number) =>
spoolmanMode ? api.restoreSpoolmanInventorySpool(id) : api.restoreSpool(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
refreshSpoolQueries();
showToast(t('inventory.spoolRestored'), 'success');
},
onError: (error: Error) => {
@@ -948,9 +965,15 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
// spools that haven't been assigned a storage location yet.
if (storageLocationFilter) {
if (storageLocationFilter === '__none__') {
filtered = filtered.filter((s) => !s.storage_location?.trim());
filtered = filtered.filter((s) => !s.location_id && !s.storage_location?.trim());
} else {
filtered = filtered.filter((s) => s.storage_location?.trim() === storageLocationFilter);
const locId = Number(storageLocationFilter);
const locName = storageLocations.find((l) => l.id === locId)?.name?.trim().toLowerCase();
filtered = filtered.filter((s) => {
if (s.location_id != null) return s.location_id === locId;
if (locName) return (s.storage_location || '').trim().toLowerCase() === locName;
return false;
});
}
}
@@ -967,11 +990,22 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
}
return filtered;
}, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold]);
}, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations]);
// Reset page on filter changes
const resetPage = () => setPageIndex(0);
const setStorageLocationFilter = useCallback((value: string) => {
setSearchParams((prev) => {
prev.delete('location_id');
if (value) {
prev.set('location_id', value);
}
return prev;
}, { replace: true });
resetPage();
}, [setSearchParams]);
// Unique values for filter dropdowns
const uniqueMaterials = [...new Set(spools?.map((s) => s.material) || [])].sort();
const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];
@@ -984,8 +1018,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
});
// #1400: storage-location distinct values. `.trim()` so accidental
// trailing whitespace doesn't show up as a separate option.
const uniqueStorageLocations = [...new Set(spools?.map((s) => s.storage_location?.trim()).filter(Boolean) as string[] || [])].sort();
const hasUnsetStorageLocation = (spools ?? []).some((s) => !s.storage_location?.trim());
const hasUnsetStorageLocation = (spools ?? []).some((s) => !s.location_id && !s.storage_location?.trim());
// Check if any filters are non-default
const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search;
@@ -1111,9 +1144,12 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
setBrandFilter('');
setCategoryFilter('');
setSpoolFilter('');
setStorageLocationFilter('');
setStockFilter('all');
setSearch('');
setSearchParams((prev) => {
prev.delete('location_id');
return prev;
}, { replace: true });
resetPage();
};
@@ -1151,6 +1187,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
{exportingCsv ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
{t('inventory.csv.exportButton', 'Export CSV')}
</Button>
<Button variant="secondary" onClick={() => setLocationsModalOpen(true)}>
<MapPin className="w-4 h-4" />
{t('locations.manage')}
</Button>
<Button
variant="secondary"
disabled={filteredSpools.length === 0}
@@ -1577,10 +1617,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
{/* Storage location dropdown chip (#1400) — only render when at
least one spool carries a storage location, otherwise it's noise
(matches the category chip pattern). */}
{(uniqueStorageLocations.length > 0 || storageLocationFilter) && (
{(storageLocations.length > 0 || storageLocationFilter) && (
<select
value={storageLocationFilter}
onChange={(e) => { setStorageLocationFilter(e.target.value); resetPage(); }}
onChange={(e) => { setStorageLocationFilter(e.target.value); }}
className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
storageLocationFilter
? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
@@ -1588,8 +1628,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
}`}
>
<option value="">{t('inventory.storageLocation')}</option>
{uniqueStorageLocations.map((loc) => (
<option key={loc} value={loc}>{loc}</option>
{storageLocations.map((loc) => (
<option key={loc.id} value={String(loc.id)}>{loc.name}</option>
))}
{hasUnsetStorageLocation && (
<option value="__none__">{t('inventory.storageLocationNone')}</option>
@@ -1989,6 +2029,12 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
}}
/>
)}
<LocationsModal
open={locationsModalOpen}
onClose={() => setLocationsModalOpen(false)}
onPickLocation={(id) => setStorageLocationFilter(String(id))}
/>
</div>
);
}
+163 -229
View File
@@ -36,7 +36,6 @@ import { TwoFactorSettings } from '../components/TwoFactorSettings';
import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
import { SecurityStatusCard } from '../components/SecurityStatusCard';
import { APIBrowser } from '../components/APIBrowser';
import { Toggle } from '../components/Toggle';
import { virtualPrinterApi, spoolbuddyApi } from '../api/client';
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
import { availableLanguages } from '../i18n';
@@ -91,8 +90,8 @@ registerSettingsSearch({ labelKey: 'settings.sessionPolicy.title', labelFallback
registerSettingsSearch({ labelKey: 'settings.email.smtpSettings', labelFallback: 'SMTP Configuration', tab: 'users', subTab: 'email', keywords: 'smtp email send server port password auth starttls ssl', anchor: 'card-smtp' });
registerSettingsSearch({ labelKey: 'settings.ldap.title', labelFallback: 'LDAP Authentication', tab: 'users', subTab: 'ldap', keywords: 'ldap active directory ad authentication bind dn search base group mapping', anchor: 'card-ldap' });
registerSettingsSearch({ labelKey: 'settings.tabs.backup', tab: 'backup', keywords: 'backup github restore download cloud sync profiles archives', anchor: 'card-backup' });
// Sidebar Links (external links settings is rendered in the General tab)
registerSettingsSearch({ labelKey: 'externalLinks.title', labelFallback: 'Sidebar Links', tab: 'general', keywords: 'sidebar links external custom navigation url add', anchor: 'card-sidebar-links' });
// Sidebar (system pages and external links settings is rendered in the General tab)
registerSettingsSearch({ labelKey: 'externalLinks.sidebarLayout', labelFallback: 'Sidebar', tab: 'general', keywords: 'sidebar layout links pages hide show external custom navigation url add', anchor: 'card-sidebar-links' });
// Filament tab — integrations
registerSettingsSearch({ labelKey: 'settings.filamentTracking', tab: 'filament', keywords: 'spoolman filament tracking inventory sync remote integration', anchor: 'card-spoolman' });
registerSettingsSearch({ labelKey: 'settings.catalog.spoolCatalog', labelFallback: 'Spool Catalog', tab: 'filament', keywords: 'spool catalog entries brand material reset import export', anchor: 'card-spool-catalog' });
@@ -265,42 +264,6 @@ export function SettingsPage() {
showToast(t('settings.toast.settingsSaved'), 'success');
};
const handleResetSidebarOrder = () => {
localStorage.removeItem('sidebarOrder');
window.location.reload();
};
const isDefaultSidebarEnabled = !!localSettings?.default_sidebar_order;
const handleToggleDefaultSidebarOrder = async (enabled: boolean) => {
try {
if (enabled) {
let orderArr: string[];
const stored = localStorage.getItem('sidebarOrder');
try {
orderArr = stored ? JSON.parse(stored) : defaultNavItems.map(i => i.id);
} catch {
orderArr = defaultNavItems.map(i => i.id);
}
if (!Array.isArray(orderArr) || orderArr.length === 0) {
orderArr = defaultNavItems.map(i => i.id);
}
const payload = JSON.stringify({ order: orderArr });
await api.updateSettings({ default_sidebar_order: payload });
setLocalSettings(prev => prev ? { ...prev, default_sidebar_order: payload } : prev);
showToast(t('settings.sidebarDefaultSet'), 'success');
} else {
await api.updateSettings({ default_sidebar_order: '' });
setLocalSettings(prev => prev ? { ...prev, default_sidebar_order: '' } : prev);
showToast(t('settings.sidebarDefaultCleared'), 'success');
}
queryClient.invalidateQueries({ queryKey: ['settings'] });
queryClient.invalidateQueries({ queryKey: ['default-sidebar-order'] });
} catch {
showToast(t('settings.sidebarDefaultFailed'), 'error');
}
};
const { data: settings, isLoading } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
@@ -1608,35 +1571,6 @@ export function SettingsPage() {
{t('settings.defaultPrinterDescription')}
</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('settings.sidebarOrder')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.sidebarOrderDescription')}
{authEnabled && hasPermission('settings:update') && ` ${t('settings.sidebarOrderSetDefaultHint')}`}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="secondary"
size="sm"
onClick={handleResetSidebarOrder}
>
<RotateCcw className="w-4 h-4" />
{t('settings.reset')}
</Button>
{authEnabled && hasPermission('settings:update') && (
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray whitespace-nowrap">{t('settings.setDefault')}</span>
<Toggle
checked={isDefaultSidebarEnabled}
onChange={handleToggleDefaultSidebarOrder}
disabled={isLoading}
/>
</div>
)}
</div>
</div>
</CardContent>
</Card>
@@ -2259,13 +2193,168 @@ export function SettingsPage() {
)}
</CardContent>
</Card>
{/* Data Management */}
<Card id="card-data">
<CardHeader>
<h2 className="text-lg font-semibold text-white">{t('settings.dataManagement')}</h2>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('settings.clearNotificationLogs')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.clearNotificationLogsDescription')}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowClearLogsConfirm(true)}
>
<Trash2 className="w-4 h-4" />
{t('common.clear')}
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('settings.resetUiPreferences')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.resetUiPreferencesDescription')}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowClearStorageConfirm(true)}
>
<Trash2 className="w-4 h-4" />
{t('settings.reset')}
</Button>
</div>
<div className="pt-4 border-t border-bambu-dark-tertiary">
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('settings.storageUsage', 'Storage Usage')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.storageUsageDescription', 'Breakdown of data usage by category')}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleStorageUsageRefresh}
disabled={storageUsageFetching || storageUsageRefreshing}
>
<RefreshCw
className={`w-4 h-4 ${storageUsageFetching || storageUsageRefreshing ? 'animate-spin' : ''}`}
/>
{t('common.refresh', 'Refresh')}
</Button>
</div>
<div className="mt-3">
{storageUsageLoading ? (
<div className="flex items-center gap-2 text-sm text-bambu-gray">
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.loading', 'Loading')}
</div>
) : storageUsage ? (
<>
<div className="w-full h-3 bg-bambu-dark rounded-full overflow-hidden flex">
{storageUsage.categories
.filter((category) => category.bytes > 0)
.map((category, index) => (
<div
key={category.key}
className={`${getStorageColor(category.key, index)} h-full`}
style={{ width: `${category.percent_of_total}%` }}
title={`${category.label}: ${category.formatted}`}
/>
))}
</div>
<div className="mt-3 flex flex-wrap gap-3">
{storageUsage.categories
.filter((category) => category.bytes > 0)
.map((category, index) => (
<div key={category.key} className="flex items-center gap-2 text-xs">
<span
className={`w-3 h-3 rounded-full ${getStorageColor(category.key, index)}`}
/>
<span className="text-bambu-gray">{category.label}</span>
<span className="text-white">{category.formatted}</span>
<span className="text-bambu-gray">({category.percent_of_total.toFixed(1)}%)</span>
</div>
))}
</div>
<div className="mt-2 text-xs text-bambu-gray">
{t('settings.storageUsageTotal', 'Total')}: <span className="text-white">{storageUsage.total_formatted}</span>
{storageUsage.scan_errors > 0 && (
<span className="ml-2 text-amber-400">
{t('settings.storageUsageErrors', 'Scan errors')}: {storageUsage.scan_errors}
</span>
)}
</div>
{storageUsage.other_breakdown?.length > 0 && (
<div className="mt-4">
<p className="text-xs text-bambu-gray mb-2">
{t('settings.storageUsageOtherBreakdown', 'Other breakdown')}
</p>
<div className="space-y-2">
{storageUsage.other_breakdown.map((item) => (
<div key={`${item.bucket}-${item.kind}`} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="text-white">{item.label}</span>
<span
className={`px-2 py-0.5 rounded-full border ${
item.kind === 'system'
? 'border-slate-500 text-slate-300'
: 'border-bambu-green text-bambu-green'
}`}
>
{item.kind === 'system'
? t('settings.storageUsageSystem', 'System')
: t('settings.storageUsageData', 'Data')}
</span>
</div>
<div className="flex items-center gap-2 text-bambu-gray">
<span className="text-white">{item.formatted}</span>
<span>({item.percent_of_total.toFixed(1)}%)</span>
</div>
</div>
))}
</div>
</div>
)}
</>
) : (
<p className="text-sm text-bambu-gray">
{t('settings.storageUsageUnavailable', 'Storage usage data is unavailable')}
</p>
)}
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary">
<div>
<p className="text-white">{t('settings.backupRestore')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.backupRestoreDescription')}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleTabChange('backup')}
>
<Database className="w-4 h-4" />
{t('settings.goToBackup')}
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Third Column - Sidebar Links & Updates */}
{/* Third Column - Updates & Sidebar Links */}
<div className="space-y-3 flex-1 lg:max-w-sm">
{/* Sidebar Links */}
<ExternalLinksSettings />
<Card id="card-updates">
<CardHeader>
<h2 className="text-lg font-semibold text-white">{t('settings.updates')}</h2>
@@ -2446,163 +2535,8 @@ export function SettingsPage() {
</CardContent>
</Card>
{/* Data Management */}
<Card id="card-data">
<CardHeader>
<h2 className="text-lg font-semibold text-white">{t('settings.dataManagement')}</h2>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('settings.clearNotificationLogs')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.clearNotificationLogsDescription')}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowClearLogsConfirm(true)}
>
<Trash2 className="w-4 h-4" />
{t('common.clear')}
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('settings.resetUiPreferences')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.resetUiPreferencesDescription')}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowClearStorageConfirm(true)}
>
<Trash2 className="w-4 h-4" />
{t('settings.reset')}
</Button>
</div>
<div className="pt-4 border-t border-bambu-dark-tertiary">
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('settings.storageUsage', 'Storage Usage')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.storageUsageDescription', 'Breakdown of data usage by category')}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleStorageUsageRefresh}
disabled={storageUsageFetching || storageUsageRefreshing}
>
<RefreshCw
className={`w-4 h-4 ${storageUsageFetching || storageUsageRefreshing ? 'animate-spin' : ''}`}
/>
{t('common.refresh', 'Refresh')}
</Button>
</div>
<div className="mt-3">
{storageUsageLoading ? (
<div className="flex items-center gap-2 text-sm text-bambu-gray">
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.loading', 'Loading')}
</div>
) : storageUsage ? (
<>
<div className="w-full h-3 bg-bambu-dark rounded-full overflow-hidden flex">
{storageUsage.categories
.filter((category) => category.bytes > 0)
.map((category, index) => (
<div
key={category.key}
className={`${getStorageColor(category.key, index)} h-full`}
style={{ width: `${category.percent_of_total}%` }}
title={`${category.label}: ${category.formatted}`}
/>
))}
</div>
<div className="mt-3 flex flex-wrap gap-3">
{storageUsage.categories
.filter((category) => category.bytes > 0)
.map((category, index) => (
<div key={category.key} className="flex items-center gap-2 text-xs">
<span
className={`w-3 h-3 rounded-full ${getStorageColor(category.key, index)}`}
/>
<span className="text-bambu-gray">{category.label}</span>
<span className="text-white">{category.formatted}</span>
<span className="text-bambu-gray">({category.percent_of_total.toFixed(1)}%)</span>
</div>
))}
</div>
<div className="mt-2 text-xs text-bambu-gray">
{t('settings.storageUsageTotal', 'Total')}: <span className="text-white">{storageUsage.total_formatted}</span>
{storageUsage.scan_errors > 0 && (
<span className="ml-2 text-amber-400">
{t('settings.storageUsageErrors', 'Scan errors')}: {storageUsage.scan_errors}
</span>
)}
</div>
{storageUsage.other_breakdown?.length > 0 && (
<div className="mt-4">
<p className="text-xs text-bambu-gray mb-2">
{t('settings.storageUsageOtherBreakdown', 'Other breakdown')}
</p>
<div className="space-y-2">
{storageUsage.other_breakdown.map((item) => (
<div key={`${item.bucket}-${item.kind}`} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="text-white">{item.label}</span>
<span
className={`px-2 py-0.5 rounded-full border ${
item.kind === 'system'
? 'border-slate-500 text-slate-300'
: 'border-bambu-green text-bambu-green'
}`}
>
{item.kind === 'system'
? t('settings.storageUsageSystem', 'System')
: t('settings.storageUsageData', 'Data')}
</span>
</div>
<div className="flex items-center gap-2 text-bambu-gray">
<span className="text-white">{item.formatted}</span>
<span>({item.percent_of_total.toFixed(1)}%)</span>
</div>
</div>
))}
</div>
</div>
)}
</>
) : (
<p className="text-sm text-bambu-gray">
{t('settings.storageUsageUnavailable', 'Storage usage data is unavailable')}
</p>
)}
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary">
<div>
<p className="text-white">{t('settings.backupRestore')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.backupRestoreDescription')}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleTabChange('backup')}
>
<Database className="w-4 h-4" />
{t('settings.goToBackup')}
</Button>
</div>
</CardContent>
</Card>
{/* Sidebar Links */}
<ExternalLinksSettings />
</div>
</div>
)}
+19
View File
@@ -0,0 +1,19 @@
import type { QueryClient } from '@tanstack/react-query';
/** React Query key for GET /inventory/locations (catalog + spool counts). */
export const inventoryLocationsQueryKey = ['inventory-locations'] as const;
export function invalidateInventoryLocations(queryClient: QueryClient) {
return queryClient.invalidateQueries({ queryKey: inventoryLocationsQueryKey });
}
/** Refresh spool list and location counts after inventory mutations. */
export function invalidateSpoolAndLocationQueries(
queryClient: QueryClient,
spoolsQueryKey: readonly string[],
) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: [...spoolsQueryKey] }),
invalidateInventoryLocations(queryClient),
]);
}
+41
View File
@@ -0,0 +1,41 @@
export const SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY = 'sidebarHiddenSystemItems';
export const SIDEBAR_ORDER_KEY = 'sidebarOrder';
export const SIDEBAR_LAYOUT_CHANGED_EVENT = 'sidebar-layout-changed';
export function isExternalSidebarItemId(id: string): boolean {
return id.startsWith('ext-');
}
export function getSidebarOrder(defaultOrder: string[]): string[] {
const stored = localStorage.getItem(SIDEBAR_ORDER_KEY);
if (!stored) return defaultOrder;
try {
const parsed = JSON.parse(stored);
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : defaultOrder;
} catch {
return defaultOrder;
}
}
export function saveSidebarOrder(order: string[]) {
localStorage.setItem(SIDEBAR_ORDER_KEY, JSON.stringify(order));
window.dispatchEvent(new CustomEvent(SIDEBAR_LAYOUT_CHANGED_EVENT));
}
export function getHiddenSidebarSystemItemIds(): string[] {
const stored = localStorage.getItem(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY);
if (!stored) return [];
try {
const parsed = JSON.parse(stored);
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
} catch {
return [];
}
}
export function saveHiddenSidebarSystemItemIds(ids: string[]) {
localStorage.setItem(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(Array.from(new Set(ids))));
window.dispatchEvent(new CustomEvent(SIDEBAR_LAYOUT_CHANGED_EVENT));
}
+1
View File
@@ -13,6 +13,7 @@ export default defineConfig({
},
},
setupFiles: ['./src/__tests__/setup.ts'],
testTimeout: 10000,
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {