feat(inventory): structured storage locations catalog (#1505)

This commit is contained in:
Poltavtcev 2026-06-17 11:33:23 +02:00 committed by GitHub
parent e8f0698ae1
commit af5d24e289
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
132 changed files with 2757 additions and 9573 deletions

View file

@ -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": [],
}

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

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)

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)

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."""

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",

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")

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

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

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):

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

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")

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."""

View file

@ -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),

View file

@ -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

View file

@ -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(

View file

@ -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",

View file

@ -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",

View file

@ -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

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

View file

@ -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
docs/storage-locations.md Normal file
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)

View file

@ -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',
);
});
});
});

View file

@ -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([]),

View file

@ -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');
});
});

View file

@ -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);

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([])),

View file

@ -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([])),
);
}

View file

@ -1317,6 +1317,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 +2661,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 +5094,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: () =>

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>
);
}

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}
/>

View file

@ -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>
);

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;

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':

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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: 'スプール在庫管理',

View file

@ -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을 불러올 수 없음 — 브라우저가 혼합 콘텐츠를 차단함',

View file

@ -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',

View file

@ -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',

View file

@ -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: '耗材库存',

View file

@ -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: '耗材庫存',

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>
);
}

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),
]);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.5 KiB

View file

@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 50">
<!-- Left wire: horizontal from left edge, then down to extruder left inlet -->
<line x1="0" y1="0" x2="10" y2="0" stroke="#909090" stroke-width="2" />
<line x1="10" y1="0" x2="10" y2="50" stroke="#909090" stroke-width="2" />
<!-- Right wire: horizontal from right edge, then down to extruder right inlet -->
<line x1="40" y1="0" x2="30" y2="0" stroke="#909090" stroke-width="2" />
<line x1="30" y1="0" x2="30" y2="50" stroke="#909090" stroke-width="2" />
</svg>

Before

Width:  |  Height:  |  Size: 537 B

View file

@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 50">
<!-- Vertical lines from slots down to horizontal bar -->
<line x1="28" y1="0" x2="28" y2="14" stroke="#909090" stroke-width="2" />
<line x1="82" y1="0" x2="82" y2="14" stroke="#909090" stroke-width="2" />
<line x1="138" y1="0" x2="138" y2="14" stroke="#909090" stroke-width="2" />
<line x1="192" y1="0" x2="192" y2="14" stroke="#909090" stroke-width="2" />
<!-- Horizontal bar across all slots -->
<line x1="28" y1="14" x2="192" y2="14" stroke="#909090" stroke-width="2" />
<!-- Center hub box -->
<rect x="96" y="8" width="28" height="12" rx="2" fill="#c0c0c0" stroke="#909090" stroke-width="1" />
<!-- Wire from hub: down, then right to edge (at same level as hub horizontal bar) -->
<line x1="110" y1="20" x2="110" y2="35" stroke="#909090" stroke-width="2" />
<line x1="110" y1="35" x2="220" y2="35" stroke="#909090" stroke-width="2" />
</svg>

Before

Width:  |  Height:  |  Size: 937 B

View file

@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 50">
<!-- Vertical lines from slots down to horizontal bar -->
<line x1="28" y1="0" x2="28" y2="14" stroke="#909090" stroke-width="2" />
<line x1="82" y1="0" x2="82" y2="14" stroke="#909090" stroke-width="2" />
<line x1="138" y1="0" x2="138" y2="14" stroke="#909090" stroke-width="2" />
<line x1="192" y1="0" x2="192" y2="14" stroke="#909090" stroke-width="2" />
<!-- Horizontal bar across all slots -->
<line x1="28" y1="14" x2="192" y2="14" stroke="#909090" stroke-width="2" />
<!-- Center hub box -->
<rect x="96" y="8" width="28" height="12" rx="2" fill="#c0c0c0" stroke="#909090" stroke-width="1" />
<!-- Wire from hub: down, then left to edge (at same level as hub horizontal bar) -->
<line x1="110" y1="20" x2="110" y2="35" stroke="#909090" stroke-width="2" />
<line x1="0" y1="35" x2="110" y2="35" stroke="#909090" stroke-width="2" />
</svg>

Before

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

View file

@ -1 +0,0 @@
<svg id="Layer_1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m15 12h-6c-1.103 0-2 .897-2 2v4c0 1.103.897 2 2 2h6c1.103 0 2-.897 2-2v-4c0-1.103-.897-2-2-2zm1 6c0 .552-.448 1-1 1h-6c-.551 0-1-.448-1-1v-4c0-.552.449-1 1-1h6c.552 0 1 .448 1 1zm-2.5-2.5c0 .276-.224.5-.5.5h-2c-.276 0-.5-.224-.5-.5s.224-.5.5-.5h2c.276 0 .5.224.5.5zm6-13.5h-1.5v-1.5c0-.276-.224-.5-.5-.5s-.5.224-.5.5v1.5h-10v-1.5c0-.276-.224-.5-.5-.5s-.5.224-.5.5v1.5h-1.5c-2.481 0-4.5 2.019-4.5 4.5v13c0 2.481 2.019 4.5 4.5 4.5h15c2.481 0 4.5-2.019 4.5-4.5v-13c0-2.481-2.019-4.5-4.5-4.5zm-15 1h15c1.93 0 3.5 1.57 3.5 3.5v1.5h-22v-1.5c0-1.93 1.57-3.5 3.5-3.5zm15 20h-15c-1.93 0-3.5-1.57-3.5-3.5v-10.5h22v10.5c0 1.93-1.57 3.5-3.5 3.5z"/></svg>

Before

Width:  |  Height:  |  Size: 745 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -1,51 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 511.999 511.999" style="enable-background:new 0 0 511.999 511.999;" xml:space="preserve">
<g>
<g>
<path d="M508.745,246.041c-4.574-6.257-113.557-153.206-252.748-153.206S7.818,239.784,3.249,246.035
c-4.332,5.936-4.332,13.987,0,19.923c4.569,6.257,113.557,153.206,252.748,153.206s248.174-146.95,252.748-153.201
C513.083,260.028,513.083,251.971,508.745,246.041z M255.997,385.406c-102.529,0-191.33-97.533-217.617-129.418
c26.253-31.913,114.868-129.395,217.617-129.395c102.524,0,191.319,97.516,217.617,129.418
C447.361,287.923,358.746,385.406,255.997,385.406z"/>
</g>
</g>
<g>
<g>
<path d="M255.997,154.725c-55.842,0-101.275,45.433-101.275,101.275s45.433,101.275,101.275,101.275
s101.275-45.433,101.275-101.275S311.839,154.725,255.997,154.725z M255.997,323.516c-37.23,0-67.516-30.287-67.516-67.516
s30.287-67.516,67.516-67.516s67.516,30.287,67.516,67.516S293.227,323.516,255.997,323.516z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1 +0,0 @@
<svg id="Layer_1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><g fill="rgb(0,0,0)"><path d="m6.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m12.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m18.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m22 13.5c-1.07 0-1.61.65-2.05 1.18-.44.52-.71.82-1.29.82s-.85-.3-1.29-.82c-.44-.53-.98-1.18-2.05-1.18s-1.61.65-2.05 1.18c-.44.52-.71.82-1.28.82s-.85-.3-1.28-.82c-.44-.53-.98-1.18-2.05-1.18s-1.61.65-2.05 1.18c-.44.52-.71.82-1.28.82s-.84-.3-1.28-.82c-.44-.53-.98-1.18-2.05-1.18-.28 0-.5.22-.5.5s.22.5.5.5c.57 0 .84.3 1.28.82.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.28-.82s.85.3 1.28.82c.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.28-.82s.85.3 1.29.82c.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.29-.82.28 0 .5-.22.5-.5s-.22-.5-.5-.5z"/><path d="m21 18.5h-18c-.83 0-1.5.67-1.5 1.5v1c0 .83.67 1.5 1.5 1.5h18c.83 0 1.5-.67 1.5-1.5v-1c0-.83-.67-1.5-1.5-1.5zm.5 2.5c0 .28-.22.5-.5.5h-18c-.28 0-.5-.22-.5-.5v-1c0-.28.22-.5.5-.5h18c.28 0 .5.22.5.5z"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,44 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 476.912 476.912" style="enable-background:new 0 0 476.912 476.912;" xml:space="preserve">
<g>
<g>
<path d="M461.776,209.408L249.568,4.52c-6.182-6.026-16.042-6.026-22.224,0L15.144,209.4c-3.124,3.015-4.888,7.17-4.888,11.512
c0,8.837,7.164,16,16,16h28.2v224c0,8.837,7.163,16,16,16h112c8.837,0,16-7.163,16-16v-128h80v128c0,8.837,7.163,16,16,16h112
c8.837,0,16-7.163,16-16v-224h28.2c4.338,0,8.489-1.761,11.504-4.88C468.301,225.678,468.129,215.549,461.776,209.408z
M422.456,220.912c-8.837,0-16,7.163-16,16v224h-112v-128c0-8.837-7.163-16-16-16h-80c-8.837,0-16,7.163-16,16v128h-112v-224
c0-8.837-7.163-16-16-16h-28.2l212.2-204.88l212.28,204.88H422.456z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,6 +0,0 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.4759 7.06822H13.7562L8.74263 11.2268C8.514 11.416 8.10431 11.416 7.87554 11.227C7.87545 11.2269 7.87536 11.2268 7.87528 11.2268L2.86167 7.06822H4.142C4.54877 7.06822 4.93613 6.94338 5.23267 6.71974C5.52893 6.49633 5.76004 6.14967 5.76004 5.72506V1.84316C5.76004 1.80403 5.78049 1.72911 5.88953 1.64688C5.99827 1.56488 6.16993 1.5 6.37809 1.5H10.2398C10.448 1.5 10.6196 1.56488 10.7284 1.64688C10.8374 1.72911 10.8579 1.80403 10.8579 1.84316V5.72506C10.8579 6.14967 11.089 6.49633 11.3852 6.71974C11.6818 6.94338 12.0691 7.06822 12.4759 7.06822ZM2.36979 7.09452C2.36773 7.09555 2.36658 7.096 2.36652 7.09597C2.36645 7.09594 2.36748 7.09542 2.36979 7.09452ZM14.2475 7.09456C14.2498 7.09545 14.2508 7.09596 14.2507 7.096C14.2507 7.09603 14.2495 7.09558 14.2475 7.09456Z" stroke="#6B6B6B"/>
<path d="M3.80389 10.668C3.58699 10.7742 3.42822 10.9007 3.42822 11.0895C3.42822 11.673 4.95994 11.673 4.95994 12.2548C4.95994 12.8383 3.42822 12.8383 3.42822 13.42C3.42822 14.0035 4.95994 14.0035 4.95994 14.587C4.95994 15.1704 3.42822 15.1704 3.42822 15.7539" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
<path d="M8.63467 14.1348C8.88288 14.2477 9.07518 14.381 9.07518 14.5867C9.07518 15.1702 7.54346 15.1702 7.54346 15.7536" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
<path d="M11.893 11.4316C12.3223 11.7065 13.1899 11.8161 13.1899 12.2546C13.1899 12.838 11.6582 12.838 11.6582 13.4198C11.6582 14.0033 13.1899 14.0033 13.1899 14.5867C13.1899 15.1702 11.6582 15.1702 11.6582 15.7537" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,4 +0,0 @@
<svg width="36" height="54" viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.8131 0.00537678C18.4463 -0.150913 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00537678ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z" fill="#D0D0D0"/>
<path d="M8 46C12 48 24 48 28 46C26 50 22 52 18 52C14 52 10 50 8 46Z" fill="#1F8FEB"/>
</svg>

Before

Width:  |  Height:  |  Size: 710 B

View file

@ -1,4 +0,0 @@
<svg width="36" height="54" viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z" fill="#1F8FEB"/>
<path d="M17.7948 0.00537678C18.4273 -0.150913 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0133678 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00537678ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z" fill="#D0D0D0"/>
</svg>

Before

Width:  |  Height:  |  Size: 765 B

View file

@ -1,4 +0,0 @@
<svg width="35" height="53" viewBox="0 0 35 53" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.3165 0.00379674C17.932 -0.149588 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148377 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.00379674ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z" fill="#D0D0D0"/>
<path d="M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z" fill="#1F8FEB"/>
</svg>

Before

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 98 KiB

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="m6.443,4.08L4.304.567,5.157.048l2.14,3.513-.854.52Zm13.557,7.92c0,2.323-1.01,4.528-2.771,6.051-.781.674-1.229,1.641-1.229,2.653v3.296h-8v-3.295c0-1.007-.456-1.982-1.252-2.675-2.062-1.796-3.058-4.497-2.661-7.227.512-3.521,3.457-6.36,7.003-6.753,2.307-.256,4.527.45,6.245,1.987,1.693,1.517,2.665,3.689,2.665,5.962Zm-5,8.704c0-.239.04-.471.077-.704h-6.156c.038.233.078.467.078.705v2.295h6v-2.296Zm4-8.704c0-1.988-.85-3.89-2.332-5.217-1.502-1.344-3.438-1.964-5.469-1.738-3.1.343-5.675,2.825-6.122,5.903-.348,2.391.522,4.757,2.327,6.328.553.481.963,1.076,1.234,1.724h2.861v-5.551c-1.14-.232-2-1.242-2-2.449h1c0,.827.673,1.5,1.5,1.5s1.5-.673,1.5-1.5h1c0,1.208-.86,2.217-2,2.449v5.551h2.856c.268-.645.672-1.234,1.218-1.706,1.542-1.332,2.426-3.262,2.426-5.294Zm.696-11.433l-.854-.52-2.14,3.513.854.52,2.14-3.513Zm3.86,4.342l-3.536,1.597.412.912,3.536-1.597-.412-.912ZM.031,5.821l3.536,1.597.412-.912L.443,4.909l-.412.912Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,12 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<!-- Generator: imaengine 6.0 -->
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" viewBox="0,0,512,512" style="enable-background:new 0 0 512 512;" version="1.1">
<defs/>
<g id="layer0">
<path d="M-0.00100857,91.001L-0.000996768,361.001C-0.000995864,381.679 16.821,398.501 37.499,398.501L154.393,398.501L212.196,456.305C213.603,457.711 215.51,458.501 217.5,458.501L277.5,458.501C281.643,458.501 285,455.144 285,451.001L285,428.501L334.394,428.501L362.197,456.305C363.604,457.711 365.511,458.501 367.501,458.501L474.501,458.501C495.179,458.501 512.001,441.679 512.001,421.001L512.001,91.001C512.001,70.323 495.179,53.501 474.501,53.501L37.501,53.501C16.821,53.501 -0.00100947,70.323 -0.00100857,91.001L-0.00100857,91.001ZM496.999,91.001L496.999,421.001C496.999,433.407 486.905,443.501 474.499,443.501L436.999,443.501L436.999,121.001C436.999,116.858 433.642,113.501 429.499,113.501C425.356,113.501 421.999,116.858 421.999,121.001L421.999,443.501L370.605,443.501L342.802,415.697C341.395,414.291 339.488,413.501 337.498,413.501L277.498,413.501C273.355,413.501 269.998,416.858 269.998,421.001L269.998,443.501L220.604,443.501L162.801,385.697C161.394,384.291 159.487,383.501 157.497,383.501L37.497,383.501C25.091,383.501 14.997,373.407 14.997,361.001L14.997,91.001C14.997,78.595 25.091,68.501 37.497,68.501L421.999,68.501L421.999,91.001C421.999,95.144 425.356,98.501 429.499,98.501C433.642,98.501 436.999,95.144 436.999,91.001L436.999,68.501L474.499,68.501C486.905,68.501 496.999,78.595 496.999,91.001L496.999,91.001Z" fill="#000000"/>
<path d="M29.999,316.001L29.999,361.001C29.999,365.144 33.356,368.501 37.499,368.501L157.499,368.501C161.642,368.501 164.999,365.144 164.999,361.001L164.999,316.001C164.999,311.858 161.642,308.501 157.499,308.501L37.499,308.501C33.356,308.501 29.999,311.858 29.999,316.001L29.999,316.001ZM149.999,323.501L149.999,353.501L44.999,353.501L44.999,323.501L149.999,323.501Z" fill="#000000"/>
<path d="M29.999,241.001L29.999,286.001C29.999,290.144 33.356,293.501 37.499,293.501L157.499,293.501C161.642,293.501 164.999,290.144 164.999,286.001L164.999,241.001C164.999,236.858 161.642,233.501 157.499,233.501L37.499,233.501C33.356,233.501 29.999,236.858 29.999,241.001L29.999,241.001ZM149.999,248.501L149.999,278.501L44.999,278.501L44.999,248.501L149.999,248.501Z" fill="#000000"/>
<path d="M29.999,166.001L29.999,211.001C29.999,215.144 33.356,218.501 37.499,218.501L157.499,218.501C161.642,218.501 164.999,215.144 164.999,211.001L164.999,166.001C164.999,161.858 161.642,158.501 157.499,158.501L37.499,158.501C33.356,158.501 29.999,161.858 29.999,166.001L29.999,166.001ZM149.999,173.501L149.999,203.501L44.999,203.501L44.999,173.501L149.999,173.501Z" fill="#000000"/>
<path d="M157.499,83.501L37.499,83.501C33.356,83.501 29.999,86.858 29.999,91.001L29.999,136.001C29.999,140.144 33.356,143.501 37.499,143.501L157.499,143.501C161.642,143.501 164.999,140.144 164.999,136.001L164.999,91.001C164.999,86.858 161.642,83.501 157.499,83.501L157.499,83.501ZM149.999,98.501L149.999,128.501L44.999,128.501L44.999,98.501L149.999,98.501Z" fill="#000000"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -1 +0,0 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m22 11a1 1 0 0 0 -1 1 9 9 0 1 1 -9-9 8.9 8.9 0 0 1 4.42 1.166l-1.127 1.127a1 1 0 0 0 .707 1.707h4a1 1 0 0 0 1-1v-4a1 1 0 0 0 -1.707-.707l-1.411 1.407a10.9 10.9 0 0 0 -5.882-1.7 11 11 0 1 0 11 11 1 1 0 0 0 -1-1z"/></svg>

Before

Width:  |  Height:  |  Size: 289 B

View file

@ -1 +0,0 @@
<svg id="Layer_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m272.066 512h-32.133c-25.989 0-47.134-21.144-47.134-47.133v-10.871c-11.049-3.53-21.784-7.986-32.097-13.323l-7.704 7.704c-18.659 18.682-48.548 18.134-66.665-.007l-22.711-22.71c-18.149-18.129-18.671-48.008.006-66.665l7.698-7.698c-5.337-10.313-9.792-21.046-13.323-32.097h-10.87c-25.988 0-47.133-21.144-47.133-47.133v-32.134c0-25.989 21.145-47.133 47.134-47.133h10.87c3.531-11.05 7.986-21.784 13.323-32.097l-7.704-7.703c-18.666-18.646-18.151-48.528.006-66.665l22.713-22.712c18.159-18.184 48.041-18.638 66.664.006l7.697 7.697c10.313-5.336 21.048-9.792 32.097-13.323v-10.87c0-25.989 21.144-47.133 47.134-47.133h32.133c25.989 0 47.133 21.144 47.133 47.133v10.871c11.049 3.53 21.784 7.986 32.097 13.323l7.704-7.704c18.659-18.682 48.548-18.134 66.665.007l22.711 22.71c18.149 18.129 18.671 48.008-.006 66.665l-7.698 7.698c5.337 10.313 9.792 21.046 13.323 32.097h10.87c25.989 0 47.134 21.144 47.134 47.133v32.134c0 25.989-21.145 47.133-47.134 47.133h-10.87c-3.531 11.05-7.986 21.784-13.323 32.097l7.704 7.704c18.666 18.646 18.151 48.528-.006 66.665l-22.713 22.712c-18.159 18.184-48.041 18.638-66.664-.006l-7.697-7.697c-10.313 5.336-21.048 9.792-32.097 13.323v10.871c0 25.987-21.144 47.131-47.134 47.131zm-106.349-102.83c14.327 8.473 29.747 14.874 45.831 19.025 6.624 1.709 11.252 7.683 11.252 14.524v22.148c0 9.447 7.687 17.133 17.134 17.133h32.133c9.447 0 17.134-7.686 17.134-17.133v-22.148c0-6.841 4.628-12.815 11.252-14.524 16.084-4.151 31.504-10.552 45.831-19.025 5.895-3.486 13.4-2.538 18.243 2.305l15.688 15.689c6.764 6.772 17.626 6.615 24.224.007l22.727-22.726c6.582-6.574 6.802-17.438.006-24.225l-15.695-15.695c-4.842-4.842-5.79-12.348-2.305-18.242 8.473-14.326 14.873-29.746 19.024-45.831 1.71-6.624 7.684-11.251 14.524-11.251h22.147c9.447 0 17.134-7.686 17.134-17.133v-32.134c0-9.447-7.687-17.133-17.134-17.133h-22.147c-6.841 0-12.814-4.628-14.524-11.251-4.151-16.085-10.552-31.505-19.024-45.831-3.485-5.894-2.537-13.4 2.305-18.242l15.689-15.689c6.782-6.774 6.605-17.634.006-24.225l-22.725-22.725c-6.587-6.596-17.451-6.789-24.225-.006l-15.694 15.695c-4.842 4.843-12.35 5.791-18.243 2.305-14.327-8.473-29.747-14.874-45.831-19.025-6.624-1.709-11.252-7.683-11.252-14.524v-22.15c0-9.447-7.687-17.133-17.134-17.133h-32.133c-9.447 0-17.134 7.686-17.134 17.133v22.148c0 6.841-4.628 12.815-11.252 14.524-16.084 4.151-31.504 10.552-45.831 19.025-5.896 3.485-13.401 2.537-18.243-2.305l-15.688-15.689c-6.764-6.772-17.627-6.615-24.224-.007l-22.727 22.726c-6.582 6.574-6.802 17.437-.006 24.225l15.695 15.695c4.842 4.842 5.79 12.348 2.305 18.242-8.473 14.326-14.873 29.746-19.024 45.831-1.71 6.624-7.684 11.251-14.524 11.251h-22.148c-9.447.001-17.134 7.687-17.134 17.134v32.134c0 9.447 7.687 17.133 17.134 17.133h22.147c6.841 0 12.814 4.628 14.524 11.251 4.151 16.085 10.552 31.505 19.024 45.831 3.485 5.894 2.537 13.4-2.305 18.242l-15.689 15.689c-6.782 6.774-6.605 17.634-.006 24.225l22.725 22.725c6.587 6.596 17.451 6.789 24.225.006l15.694-15.695c3.568-3.567 10.991-6.594 18.244-2.304z"/><path d="m256 367.4c-61.427 0-111.4-49.974-111.4-111.4s49.973-111.4 111.4-111.4 111.4 49.974 111.4 111.4-49.973 111.4-111.4 111.4zm0-192.8c-44.885 0-81.4 36.516-81.4 81.4s36.516 81.4 81.4 81.4 81.4-36.516 81.4-81.4-36.515-81.4-81.4-81.4z"/></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1 +0,0 @@
<svg id="Layer_1" height="512" viewBox="0 0 32 32" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m30 17-3 3-3-3h2c0-6.0654-4.9355-11-11-11s-11 4.9346-11 11h-2c0-7.168 5.832-13 13-13s13 5.832 13 13zm0 5h-6v2h6zm-10 0h-2v2h2zm-16 0h-2v2h2zm4 0h-2v2h2zm4 0h-2v2h2zm4 0h-2v2h2z"/></svg>

Before

Width:  |  Height:  |  Size: 313 B

View file

@ -1,53 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 412.8 412.8" style="enable-background:new 0 0 412.8 412.8;" xml:space="preserve">
<g>
<g>
<path d="M378.4,225.6L304,251.2L274,234v-27.6v-27.2l30-17.2l74.4,25.6c5.2,2,11.2-1.2,12.8-6.4c2-5.2-1.2-11.2-6.4-12.8
l-57.6-19.6l54-31.2c4.8-2.8,6.4-9.2,3.6-14c-2.8-4.8-9.2-6.4-14-3.6l-54,31.2l11.6-59.6c1.2-5.6-2.4-10.8-8-12
c-5.6-1.2-10.8,2.4-12,8l-15.2,77.2l-30,17.2l-22.8-13.2l-0.4-0.4l-23.2-13.6v-34.4L276,48.8c4.4-3.6,4.8-10,0.8-14.4
c-3.6-4.4-10-4.8-14.4-0.8l-45.6,40V10.4c0-5.6-4.4-10.4-10.4-10.4C200.8,0,196,4.4,196,10.4v62.4l-45.6-39.6
C146,29.6,139.6,30,136,34c-3.6,4.4-3.2,10.8,0.8,14.4L196,100v34.4L172.8,148l-23.2,13.6l-30-17.2l-15.2-77.2
c-1.2-5.6-6.4-9.2-12-8c-5.6,1.2-9.2,6.4-8,12L96,130.8L42,99.6c-4.8-2.8-11.2-1.2-14,3.6s-1.2,11.2,3.6,14l54,31.2L28,168
c-5.2,2-8.4,7.6-6.4,12.8s7.6,8.4,12.8,6.4l74.4-25.6l30,17.2v27.6v27.2h0.4l-30,17.2l-74.4-25.6c-5.2-2-11.2,1.2-12.8,6.4
c-2,5.2,1.2,11.2,6.4,12.8L86,264l-54,31.2c-4.8,2.8-6.4,9.2-3.6,14c2.8,4.8,9.2,6.4,14,3.6l54-31.2l-11.6,59.6
c-1.2,5.6,2.4,10.8,8,12c5.6,1.2,10.8-2.4,12-8L120,268l30-17.2l23.6,13.6l23.2,13.6v34.4L137.6,364c-4.4,3.6-4.8,10-0.8,14.4
c3.6,4.4,10,4.8,14.4,0.8l45.6-40v63.2c0,5.6,4.4,10.4,10.4,10.4c5.6,0,10.4-4.4,10.4-10.4V340l45.6,40c4.4,3.6,10.8,3.2,14.4-0.8
c3.6-4.4,3.2-10.8-0.8-14.4l-60-52v-34.4l23.2-13.6l23.2-13.6l30,17.2l15.2,77.2c1.2,5.6,6.4,9.2,12,8c5.6-1.2,9.2-6.4,8-12
L316.8,282l54,31.2c4.8,2.8,11.2,1.2,14-3.6c2.8-4.8,1.2-11.2-3.6-14l-54-31.2l57.6-19.6c5.2-2,8.4-7.6,6.4-12.8
C389.2,226.8,383.6,223.6,378.4,225.6z M252.4,206.4v27.2l-23.2,13.6l-22.8,13.2l-23.6-13.6l-23.2-13.6v-26.8v-27.2l23.2-13.6
L206,152l23.2,13.6l0.4,0.4l22.8,13.2V206.4z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -1,6 +0,0 @@
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.4005 12.7763C17.649 12.8443 17.907 12.6982 17.9606 12.4463C18.1963 11.3405 18.1993 10.1959 17.9674 9.08583C17.7037 7.8233 17.1438 6.64152 16.3337 5.63787C15.5236 4.63422 14.4866 3.83744 13.3082 3.3132C12.1298 2.78897 10.8436 2.55228 9.55579 2.62265C8.26794 2.69302 7.01524 3.06843 5.90095 3.71795C4.78665 4.36746 3.84266 5.27248 3.14678 6.35842C2.45089 7.44436 2.02303 8.68012 1.89846 9.96387C1.78893 11.0926 1.91663 12.23 2.27134 13.3036C2.35217 13.5482 2.62452 13.6653 2.8641 13.5706V13.5706C3.10368 13.4759 3.21959 13.2053 3.14058 12.9601C2.83915 12.0245 2.73177 11.0355 2.82702 10.054C2.93731 8.91737 3.31613 7.82324 3.93226 6.86177C4.54839 5.9003 5.38418 5.09901 6.37076 4.52394C7.35733 3.94887 8.46645 3.61649 9.60669 3.55418C10.7469 3.49188 11.8857 3.70144 12.929 4.16559C13.9724 4.62974 14.8905 5.33519 15.6077 6.2238C16.3249 7.11242 16.8207 8.15875 17.0542 9.27658C17.2558 10.2419 17.2569 11.2367 17.0592 12.1995C17.0073 12.4519 17.152 12.7083 17.4005 12.7763V12.7763Z" fill="#323A3D"/>
<path d="M15.4157 8.78647C15.593 8.71313 15.6782 8.50941 15.5948 8.33658C15.1923 7.50231 14.6033 6.76987 13.8715 6.19699C13.0484 5.5526 12.0726 5.13199 11.0389 4.97602C10.0053 4.82004 8.94883 4.93399 7.9722 5.30681C7.10396 5.63825 6.32502 6.16427 5.6943 6.84264C5.56365 6.98317 5.58494 7.20296 5.73272 7.32535V7.32535C5.88051 7.44775 6.09873 7.42629 6.23043 7.28674C6.78398 6.70015 7.46373 6.2447 8.22002 5.956C9.08471 5.62591 10.0201 5.52502 10.9353 5.66312C11.8504 5.80122 12.7144 6.17362 13.4432 6.74415C14.0806 7.24316 14.5957 7.8789 14.9515 8.60271C15.0362 8.77491 15.2383 8.85981 15.4157 8.78647V8.78647Z" fill="#323A3D"/>
<path d="M13.7871 9.75159L10.6357 14.0831" stroke="#323A3D" stroke-width="1.04628" stroke-linecap="round"/>
<circle cx="10.1926" cy="14.5735" r="1.32935" fill="#323A3D"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="m12.5,15.051V5h-1v10.051c-1.14.232-2,1.242-2,2.449,0,1.379,1.121,2.5,2.5,2.5s2.5-1.121,2.5-2.5c0-1.208-.86-2.217-2-2.449Zm-.5,3.949c-.827,0-1.5-.673-1.5-1.5s.673-1.5,1.5-1.5,1.5.673,1.5,1.5-.673,1.5-1.5,1.5Zm4.5-6.181V4.5c0-2.481-2.019-4.5-4.5-4.5s-4.5,2.019-4.5,4.5v8.319c-1.627,1.561-2.32,3.805-1.859,6.049.508,2.472,2.506,4.476,4.972,4.987.459.096.92.143,1.376.143,1.495,0,2.942-.503,4.111-1.454,1.525-1.241,2.4-3.08,2.4-5.044,0-1.763-.727-3.456-2-4.681Zm-1.031,8.949c-1.292,1.05-2.989,1.454-4.653,1.108-2.081-.432-3.767-2.124-4.194-4.21-.405-1.968.235-3.933,1.713-5.258l.166-.148V4.5c0-1.93,1.57-3.5,3.5-3.5s3.5,1.57,3.5,3.5v8.761l.166.148c1.166,1.046,1.834,2.537,1.834,4.091,0,1.662-.74,3.218-2.031,4.269Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 865 B

View file

@ -1,6 +0,0 @@
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.6444 8.72819C12.3737 7.93152 12.8358 6.88333 12.8815 5.7196C12.9537 3.86391 11.9465 2.21641 10.4205 1.38122C10.2905 1.31022 10.1557 1.24523 10.0186 1.18506C9.74417 1.07796 9.44933 1.01417 9.13884 1.00214C7.64057 0.943171 6.37816 2.1105 6.3204 3.60878C6.2867 4.48608 6.67301 5.28155 7.29879 5.80264C7.34813 5.83754 7.39507 5.87485 7.442 5.91336C7.92097 6.31169 8.25913 6.87129 8.37587 7.5043C8.67793 7.40923 9.00045 7.3635 9.335 7.37673C10.317 7.41765 11.1642 7.95077 11.6444 8.72819Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
<path d="M6.3842 10.1074C6.39743 9.77525 6.46723 9.45995 6.58276 9.16752C5.57669 8.98339 4.50443 9.12179 3.52844 9.63445C1.88455 10.4997 0.961517 12.1954 1.00123 13.9343C1.00484 14.0823 1.01567 14.2316 1.03252 14.3808C1.07705 14.6708 1.16971 14.9597 1.31412 15.234C2.01212 16.5602 3.65481 17.0705 4.98099 16.3713C5.75721 15.9621 6.25422 15.2304 6.39142 14.4265C6.39743 14.3664 6.40586 14.3062 6.41669 14.2472C6.53583 13.5564 6.91972 12.9174 7.52024 12.4926C6.79938 11.9523 6.34689 11.0774 6.3842 10.1074Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
<path d="M16.7327 11.0132C15.9902 10.545 15.1081 10.4813 14.3427 10.7641C14.2874 10.7881 14.232 10.811 14.1754 10.8327C13.4895 11.0854 12.7097 11.0601 12.0213 10.7147C11.8083 11.9254 10.8347 12.8592 9.62646 13.0313C9.94778 14.0747 10.6277 15.0134 11.6242 15.6416C13.1959 16.632 15.1262 16.5839 16.6124 15.6801C16.7388 15.6031 16.8627 15.5188 16.9831 15.4298C17.2129 15.2457 17.4163 15.0218 17.5812 14.7595C18.3815 13.4898 18.0012 11.8135 16.7327 11.0132Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
<path d="M9.20391 11.3717C9.76553 11.3717 10.2208 10.9164 10.2208 10.3548C10.2208 9.79317 9.76553 9.33789 9.20391 9.33789C8.64229 9.33789 8.18701 9.79317 8.18701 10.3548C8.18701 10.9164 8.64229 11.3717 9.20391 11.3717Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1 +0,0 @@
<svg height="472pt" viewBox="0 -87 472 472" width="472pt" xmlns="http://www.w3.org/2000/svg"><path d="m467.101562 26.527344c-3.039062-1.800782-6.796874-1.871094-9.898437-.179688l-108.296875 59.132813v-35.480469c-.03125-27.601562-22.398438-49.96875-50-50h-248.90625c-27.601562.03125-49.96875 22.398438-50 50v197.421875c.03125 27.601563 22.398438 49.96875 50 50h248.90625c27.601562-.03125 49.96875-22.398437 50-50v-34.835937l108.300781 59.132812c3.097657 1.691406 6.859375 1.625 9.894531-.175781 3.039063-1.804688 4.898438-5.074219 4.898438-8.601563v-227.816406c0-3.53125-1.863281-6.796875-4.898438-8.597656zm-138.203124 220.898437c-.015626 16.5625-13.4375 29.980469-30 30h-248.898438c-16.5625-.019531-29.980469-13.4375-30-30v-197.425781c.019531-16.558594 13.4375-29.980469 30-30h248.90625c16.558594.019531 29.980469 13.441406 30 30zm123.101562-1.335937-103.09375-56.289063v-81.535156l103.09375-56.285156zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 917 B

View file

@ -1,2 +0,0 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="512" height="512"><g id="Water"><path d="M24,46A16.0183,16.0183,0,0,1,8,30C8,16.0942,22.708,2.8125,23.3345,2.2539a.9983.9983,0,0,1,1.331,0C25.292,2.8125,40,16.0942,40,30A16.0183,16.0183,0,0,1,24,46ZM24,4.3721C21.1333,7.1372,10,18.6118,10,30a14,14,0,0,0,28,0C38,18.6118,26.8667,7.1372,24,4.3721Z"/><path d="M18.4976,40.5273a.9946.9946,0,0,1-.5-.1342A12.0449,12.0449,0,0,1,12,30a1,1,0,0,1,2,0,10.0373,10.0373,0,0,0,5,8.6616,1,1,0,0,1-.5019,1.8657Z"/></g></svg>

Before

Width:  |  Height:  |  Size: 548 B

View file

@ -1,73 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<g>
<path d="M256,40c-5.52,0-10,4.48-10,10s4.48,10,10,10s10-4.48,10-10S261.52,40,256,40z"/>
</g>
</g>
<g>
<g>
<path d="M466,210C466,94.206,371.794,0,256,0S46,94.206,46,210c0,96.488,66.579,180.855,159.516,203.859
c-1.591,14.119-6.958,31.441-13.568,38.051l-0.131,0.131c-18.899,0.353-32.638,3.149-42.999,8.73
C133.677,468.949,126,482.82,126,502c0,5.522,4.478,10,10,10h240c5.522,0,10-4.478,10-10c0-19.187-7.68-33.058-22.824-41.229
c-10.344-5.58-24.082-8.378-42.992-8.731l-0.132-0.132c-6.61-6.609-11.977-23.931-13.568-38.05
C399.423,390.853,466,306.486,466,210z M316,472c33.23,0,45.303,7.689,48.794,20H147.226c2.172-7.762,6.862-11.345,11.087-13.626
C166.274,474.085,178.603,472,196,472H316z M215.517,452c5.068-10.601,8.238-23.466,9.638-34.27
C235.326,419.232,245.658,420,256,420c10.342,0,20.674-0.768,30.845-2.27c1.401,10.804,4.57,23.67,9.638,34.27H215.517z
M294.015,396.179c-0.019,0.004-0.037,0.007-0.056,0.011c-24.788,5.056-51.127,5.057-75.922-0.001
c-0.017-0.004-0.035-0.007-0.052-0.01C129.918,378.227,66,299.929,66,210c0-104.767,85.233-190,190-190s190,85.233,190,190
C446,299.929,382.082,378.227,294.015,396.179z"/>
</g>
</g>
<g>
<g>
<path d="M389.606,104.994c-23.072-29.303-55.544-50.505-91.434-59.701c-5.355-1.374-10.799,1.855-12.17,7.205
c-1.37,5.35,1.855,10.798,7.205,12.169c31.66,8.112,60.314,26.828,80.686,52.7c3.426,4.352,9.716,5.077,14.043,1.67
C392.275,115.621,393.023,109.333,389.606,104.994z"/>
</g>
</g>
<g>
<g>
<path d="M256,100c-60.654,0-110,49.346-110,110s49.346,110,110,110s110-49.346,110-110S316.654,100,256,100z M256,300
c-49.626,0-90-40.374-90-90c0-49.626,40.374-90,90-90c49.626,0,90,40.374,90,90C346,259.626,305.626,300,256,300z"/>
</g>
</g>
<g>
<g>
<path d="M256,140c-38.598,0-70,31.402-70,70c0,38.598,31.402,70,70,70c38.598,0,70-31.402,70-70C326,171.402,294.598,140,256,140z
M256,260c-27.57,0-50-22.43-50-50s22.43-50,50-50s50,22.43,50,50S283.57,260,256,260z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Some files were not shown because too many files have changed in this diff Show more