Skip to content

feat(inventory): unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write support#1114

Open
netscout2001 wants to merge 12 commits intomaziggy:devfrom
netscout2001:feature/spoolman-inventory-ui
Open

feat(inventory): unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write support#1114
netscout2001 wants to merge 12 commits intomaziggy:devfrom
netscout2001:feature/spoolman-inventory-ui

Conversation

@netscout2001
Copy link
Copy Markdown
Collaborator

@netscout2001 netscout2001 commented Apr 24, 2026

Description

This PR integrates Spoolman more deeply into Bambuddy so users never have to leave the app to manage their filament, regardless of which inventory backend is active.

Unified Inventory UI for Spoolman mode
When Spoolman is enabled, the Bambuddy inventory page proxies all spool data through a new backend API (/spoolman/inventory/*). Create, edit, archive, delete and bulk-create spools — all from the same table/card UI as the internal inventory. The frontend is fully inventory-backend-agnostic.

AMS Slot Assignments (Spoolman)
A new spoolman_slot_assignments table maps (printer, ams_id, tray_id) → spoolman_spool_id locally, without touching Spoolman's spool.location field (which remains user-managed). A full CRUD API (POST/DELETE/GET /spoolman/inventory/slot-assignments) handles assignment, unassignment, and lookup. The sync routes use the slot table as a no-RFID fallback (spoolman_spool_id_hint): if a tray has no readable RFID tag the previously assigned spool ID is used to keep weight data flowing. Trays with neither RFID nor a hint produce a SkippedSpool entry in the sync response instead of silently returning null. When a printer is deleted, all its slot assignments are explicitly removed (SQLite does not enforce FK cascades without PRAGMA foreign_keys).

Storage Location field
A new "Storage Location" text field on every spool (e.g. "Shelf A, Box 3"). In internal inventory mode it is stored locally. In Spoolman mode it maps bidirectionally to Spoolman's native location field — reads on load, writes back on save, and can be explicitly cleared.

"Open in Inventory" deep-link from AMS slot hover card
The hover card shown when hovering over an AMS slot now contains an "Open in Inventory" button (for both Spoolman-linked and internally-assigned spools). Clicking navigates to /inventory?spool=<id>, which immediately opens the edit modal for that exact spool. Falls back to a targeted single-spool fetch if the spool list is not yet cached.

SpoolBuddy NFC write support for Spoolman spools (bonus — not part of the original FR)
SpoolBuddy devices can now write OpenTag3D NDEF tags for Spoolman-managed spools, not just local DB spools. The nfc/write-tag endpoint falls back to Spoolman when a spool is not found locally and encodes the tag via a new encode_opentag3d_from_mapped() path. After a successful write, nfc/write-result stores the tag UID back into Spoolman's extra.tag field using merge_spool_extra to preserve all other custom fields. Spoolman spools are also fully discoverable via NFC scan (nfc/tag-scanned) and weight sync (scale/update-spool-weight) uses Spoolman's own filament.spool_weight as core weight instead of a hardcoded 250 g fallback.

Bug Fixes

SpoolBuddy kiosk buttons broken after permission hardening
The new _APIKEY_DENIED_PERMISSIONS denylist inadvertently blocked three operations for API-key-authenticated sessions:

  • Reboot / Shutdown (queue_system_command) required SETTINGS_UPDATE → changed to INVENTORY_UPDATE
  • Update button (trigger_daemon_update) required SETTINGS_UPDATE → changed to INVENTORY_UPDATE
  • UI language sync (GET /settings) required SETTINGS_READSETTINGS_READ removed from the denylist

Scale reading 422 errors on uncalibrated devices
ScaleReadingRequest.weight_grams had an upper bound of 100 000.0. The NAU7802 24-bit ADC with default calibration_factor=1.0 produces raw values in the millions before calibration, causing every scale reading to be rejected with HTTP 422. Bounds removed.

Sync robustness (priority review matrix)

  • DB rollbacks added to all sync batch-persist except blocks and link_spool/unlink_spool DB writes (C1/C2/C3/H6)
  • Slot-map load failures promoted from debug to warning level (H1, 3 sites)
  • assign_spoolman_slot: spool verified in Spoolman before the local DB row is written — prevents ghost rows pointing at non-existent spool IDs (H2)
  • get_spoolman_slot_assignment: non-404 Spoolman errors are now re-raised instead of silently returning null — a 503 from Spoolman surfaces as 503 to the caller (H3)
  • unassign_spoolman_slot: catches 404 from Spoolman after a successful local delete and returns 200 — the local unassignment succeeded regardless of whether Spoolman still has the spool (H4)
  • sync_ams_tray non-BL RFID path: find_or_create_filament wrapped in try/except so a Spoolman timeout does not abort the whole sync cycle (H5)
  • on_ams_change: isinstance guards on ams_unit/tray_data before attribute access (CR4); ValueError from init_spoolman_client caught so an SSRF-blocked URL logs a warning and exits cleanly instead of propagating an unhandled exception (SSRF)
  • sync_all_printers: missing elif spool_tag branch added so not-found errors are reported for RFID-tagged trays too, not just no-RFID trays (asymmetry fix)

DB DDL dialect fix
active_print_spoolman and spool_usage_history migration blocks previously used INTEGER PRIMARY KEY AUTOINCREMENT (SQLite-only syntax) unconditionally. Both now branch on is_sqlite(), matching the pattern used by smart_plug_energy_snapshots.

Related Issue

Fixes #1038

Documentation

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change
  • Documentation update
  • Code refactoring
  • Performance improvement
  • Test addition or update

Changes Made

Backend

  • models/spoolman_slot_assignment.py — new ORM model, (printer_id, ams_id, tray_id) unique constraint, FK ON DELETE CASCADE
  • core/database.py — migration block for spoolman_slot_assignments table; DDL dialect fix for active_print_spoolman and spool_usage_history
  • api/routes/spoolman_inventory.py — full slot-assignment CRUD (assign, unassign, get, get_all); ghost-row prevention, stale-row cleanup, 503 propagation
  • api/routes/spoolman.pysync_printer_ams / sync_all_printers: slot-map loading, hint forwarding, SkippedSpool entries, DB rollbacks, asymmetry fix; link_spool / unlink_spool DB rollbacks
  • api/routes/printers.py — explicit SpoolmanSlotAssignment delete in delete_printer
  • app/main.pyon_ams_change: slot-map loading + hint, isinstance guards, SSRF ValueError catch, DB rollback
  • services/spoolman.pysync_ams_tray: non-BL RFID find_or_create_filament guard, hint uncached get_spool call; updated clear_location_for_removed_spools docstring
  • api/routes/_spoolman_helpers.py_map_spoolman_spool(), SSRF guard, helper utilities
  • services/opentag3d.pyencode_opentag3d_from_mapped() for Spoolman dict-based NDEF encoding
  • api/routes/spoolbuddy.py — Spoolman-aware NFC scan/write/result, weight sync; permission fixes for kiosk buttons
  • schemas/spoolbuddy.py — unbounded weight_grams
  • core/auth.pySETTINGS_READ removed from API-key denylist

Frontend

  • api/client.ts — slot-assignment API methods (assignSpoolmanSlot, unassignSpoolmanSlot, getSpoolmanSlotAssignment, getSpoolmanSlotAssignments)
  • components/AssignSpoolModal.tsx — Spoolman spool picker for AMS slot assignment
  • components/LinkSpoolModal.tsx — Spoolman link flow
  • pages/PrintersPage.tsx — AMS slot assignment UI, "Open in Inventory" deep-link
  • i18n: new keys added to all 8 locale files (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW)

Tests (T1–T9 + 3 bonus)

  • T1: link_spool → Spoolman 404/503 returns correct HTTP status
  • T2: unlink_spool → Spoolman 404 returns correct HTTP status
  • T3: get_spoolman_slot_assignment → stale row cleaned up on Spoolman 404
  • T4: sync writes slot row to DB
  • T5: sync forwards spoolman_spool_id_hint when no RFID
  • T6: non-BL RFID find_or_create_filament error → None, not raises
  • T7: hint uncached path → get_spool(hint) called
  • T8: RFID takes precedence over hint → hint path never entered
  • T9: DELETE /printers/{id} removes all slot assignments
  • Bonus: H3 503 propagation, H4 unassign on already-deleted spool, CR3 no-RFID/no-hint skipped entry

Testing

  • Backend: 3586 passed, 30 pre-existing failures (camera/timelapse hardware mocks — not introduced by this PR)

  • Frontend: 1447/1447 passed

  • Bandit security scan: 0 Medium/High findings

  • I have tested this on my local machine

Checklist

  • My code follows the project's coding style
  • I have commented my code where necessary
  • My changes generate no new warnings
  • I have tested my changes thoroughly

@netscout2001 netscout2001 requested a review from maziggy as a code owner April 24, 2026 14:25
@maziggy
Copy link
Copy Markdown
Owner

maziggy commented Apr 24, 2026

As discussed...

docker pull ghcr.io/maziggy/bambuddy:spoolman-test

@netscout2001 netscout2001 marked this pull request as draft April 26, 2026 12:29
@netscout2001 netscout2001 force-pushed the feature/spoolman-inventory-ui branch from c9b8c1f to af49b8e Compare April 26, 2026 17:47
@netscout2001 netscout2001 marked this pull request as ready for review April 26, 2026 17:50
@netscout2001 netscout2001 changed the title feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support feat(inventory): unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write support Apr 26, 2026
Copy link
Copy Markdown
Owner

@maziggy maziggy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blockers

  1. GET /settings now leaks every secret to API-key callers

backend/app/core/auth.py:32-46 removed SETTINGS_READ from the denylist (so the kiosk can read the UI language). But backend/app/api/routes/settings.py:64-148 only blanks ldap_bind_password — every other secret is returned verbatim:

  • mqtt_password
  • ha_token (Home Assistant Long-Lived Access Token)
  • prometheus_token
  • virtual_printer_access_code

Any API key (including a stolen kiosk key) can now exfiltrate every printer access code, the broker password, and the HA + Prometheus tokens.

Fix: blank or strip these fields from the GET /settings response when the caller is an API key (or unconditionally — adopt the *_set: bool pattern the virtual-printer endpoint already uses). Add a regression test in test_auth_apikey_rbac.py asserting these fields are scrubbed.

  1. Permission downgrade lets non-admins reboot Pis and ship arbitrary code over SSH

backend/app/api/routes/spoolbuddy.py:1015-1041 (queue_system_command) and :1204-1245 (trigger_daemon_update) were swapped from SETTINGS_UPDATE to INVENTORY_UPDATE. INVENTORY_UPDATE is granted to anyone who can edit a spool, but these endpoints can:

  • reboot / shutdown / restart_daemon / restart_browser on a Pi
  • SSH into the Pi as the bambuddy backend user, git fetch && git reset --hard && pip install && systemctl restart whatever's on origin/branch (i.e. arbitrary code execution)

These are admin-tier system actions, not inventory operations.

Fix: revert both endpoints to SETTINGS_UPDATE, OR add a dedicated Permission.SPOOLBUDDY_DEVICE_CONTROL to _APIKEY_DENIED_PERMISSIONS. Add tests that an INVENTORY_UPDATE-only key gets 403 on these two endpoints, and an admin gets 200.

  1. TOCTOU race in get_spoolman_slot_assignment cleanup deletes a freshly re-assigned slot

backend/app/api/routes/spoolman_inventory.py:680-683 deletes by slot.id only. The upsert in assign_spoolman_slot (:596-609) keeps the same row id, only changing
spoolman_spool_id. If a concurrent assign re-points the same slot at a valid spool while this GET is awaiting Spoolman's 404, the cleanup deletes the new, valid assignment.

Fix:
delete(SpoolmanSlotAssignment).where(
SpoolmanSlotAssignment.id == slot.id,
SpoolmanSlotAssignment.spoolman_spool_id == slot.spoolman_spool_id,
)
Plus an asyncio.gather integration test driving the race.

  1. update_spool and four siblings still silently return None on Exception

backend/app/services/spoolman.py:267-268, 364-368, 412-417, 458-459, 752-753. The PR modernized get_spool / update_spool_full / get_all_spools / delete_spool /
set_spool_archived to raise typed exceptions. It did NOT modernize update_spool, create_spool, create_filament, create_vendor, use_spool. Consequences:

  • sync_ams_tray:1145 reports a 5xx/timeout/auth failure as "Spool not found in Spoolman" — wrong cause.
  • bulk_create_spools returns failed_count: N with no per-failure reason.

Fix: route these five through the typed _request_spool helper. Update sync_ams_tray and bulk_create_spools to surface the real exception classes.

  1. SSRF rejection of Spoolman URL returns HTTP 503 instead of 400

backend/app/api/routes/spoolman.py:124-140. init_spoolman_client(url) raises ValueError from the SSRF guard, but the bare except Exception reports it as 503 "Spoolman is unreachable". Users typing http://169.254.169.254/ will retry forever, suspect networking, never realise the URL is being actively blocked.

Fix: add except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc BEFORE the broad except. Add from e to the existing 503 raise.

  1. Slot-assignment endpoints commit without rollback on failure

spoolman_inventory.py:595-610, 628-631, 680-683 all commit with no try/except: await db.rollback(). link_spool and unlink_spool in spoolman.py:797-810 and :851-854 do have proper rollbacks — these three siblings don't. On commit failure (DB lock, integrity error mid-statement) the session ends in an aborted state; reuse fails.

Fix: wrap each commit in the same try/except: await db.rollback(); raise HTTPException(...) pattern link_spool already uses.

Important — should fix in same PR

  1. _translate_spoolman_errors propagates Spoolman 4xx as Bambuddy 4xx (spoolman_inventory.py:113-114). A Spoolman 401 makes the frontend interceptor force-log-out. Map all SpoolmanClientError to 502 Bad Gateway with {"detail": ..., "upstream_status": exc.status_code}. Capture exc.response.text (truncated to ~200 chars) so 422 validation errors surface the actual field message.
  2. weight_grams accepts NaN/Infinity (schemas/spoolbuddy.py:108-117). Pydantic float still accepts +Inf/-Inf/NaN — these blow up Spoolman PATCH (non-standard JSON) and
    Integer column writes (OverflowError → 500). Add a field_validator rejecting non-finite values. Also: only ScaleReadingRequest needs to be unbounded (raw ADC);
    UpdateSpoolWeightRequest.weight_grams should re-add ge=0.0, le=100_000.0 since it's always calibrated grams feeding Spoolman / DB writes.
  3. unlink_spool writes '""' sentinel into extra.tag instead of removing the key (spoolman.py:838-839). The PATCH path correctly uses cur_extra.pop("tag", None); the
    dedicated unlink path leaves extra = {"tag": """"} visible in the Spoolman UI. Use the same pop pattern under the per-spool extra lock, or extend merge_spool_extra to drop
    a key when the value is None.
  4. update_spool_weight ignores data_origin (spoolbuddy.py:769-809). The check is if sm_client is not None, not if spool.data_origin == "spoolman". Local-DB code path is
    unreachable when Spoolman is enabled, even for local-DB-only spools. NFC scan (:486-491) correctly tries local first; weight sync should mirror that.
  5. Bulk create returns failed_count: N with no per-failure reason (spoolman_inventory.py:347-371). Same fix as B4 — capture per-iteration exceptions in the 207 body.
  6. _apply_price_if_set swallows all HTTPExceptions silently (spoolman_inventory.py:119-137). Spool created, price PATCH fails, user gets HTTP 200 with no indication price wasn't saved. Either re-raise or return 207 with warnings: ["price_not_saved: "].
  7. assign_spoolman_slot doesn't bounds-check ams_id/tray_id (spoolman_inventory.py:570-612). Schema only checks >=0. A stale UI or malicious POST creates a permanently-orphaned assignment. Add 0 <= ams_id <= 7 / 0 <= tray_id <= 3 (or look up the printer's actual layout from the registry).
  8. _get_spoolman_client_or_none silently disables Spoolman on SSRF rejection (spoolbuddy.py:55-91). Logs a warning and returns None. Every NFC scan / write / weight update silently behaves as if Spoolman were off, while the frontend status indicator may still show "connected". Either raise 503 or emit a spoolman_misconfigured WS event.
  9. nfc_tag_scanned Spoolman outage indistinguishable from "tag not registered" (spoolbuddy.py:411-428). Both return {matched: False, spool_id: None} — user scanning an unregistered tag during an outage gets zero feedback. Distinguish via a spoolman_unavailable status field.
  10. Unhandled SpoolmanClientError in link_spool/unlink_spool → opaque 500 (spoolman.py:775-780, 838-843). 401/403/422 from Spoolman becomes Internal Server Error. Add a SpoolmanClientError handler.
  11. core_weight accepted in POST/PATCH but silently dropped (spoolman_inventory.py:170-204). Comment says "schema parity but not persisted". User sends core_weight=400, gets 200, downstream calculations use 250. Either persist it (it lives on Spoolman's filament.spool_weight) or reject any non-default value with 400.
  12. Test gaps for the blockers above:
    - No test that GET /settings scrubs sensitive fields when called via API key.
    - No negative test that an INVENTORY_READ-only API key is rejected on /devices/{id}/system/command and /update.
    - No concurrency test for POST /slot-assignments (covers both the upsert and the cleanup race).
    - No PG-dialect string test for spoolman_slot_assignments table DDL (parallel to existing tests for active_print_spoolman and spool_usage_history).
    - No direct unit tests for assert_safe_spoolman_url — security boundary, deserves a focused unit suite covering userinfo URLs, IPv4-mapped IPv6, IDN, missing hostname,
    port 0.
    - No concurrent test for merge_spool_extra lock (the lock is the entire correctness guarantee for parallel NFC writes; B7 only verifies in-lock refetch, not contention).
    - AssignSpoolModal.test.tsx never sets spoolmanEnabled=true — the Spoolman-mode picker is untested, and the #1133 archived-filter fix could regress silently.
    - InventoryPageDeepLink.test.tsx covers internal-mode only.
    - InventoryPageSearch.test.ts reimplements the filter inline rather than testing the production code — extract buildSearchPredicate as a named export.

Nits (style / polish, group fix)

  • Multi-paragraph docstrings all over the new code violate the project's "one short line max" rule: assert_safe_spoolman_url (24 lines), _map_spoolman_spool (5 lines, 2
    paragraphs), 7+ Spoolman client GET-helpers, 4 inventory route handlers, _translate_spoolman_errors, update_spool_full (23 lines).
  • Historical-state phrasing in auth.py:30-32: "which was always permitted before the denylist was introduced" — drop the trailing clause.
  • fall through to broadcast + raise 502 below triplicated in spoolbuddy.py:626, 632, 638.

  • About ten WHAT comments restating the next line ("Capture data_origin before clearing", "Find the device first", "Try local DB first"). Drop them.
  • SkippedSpool.reason is free-form English — make it Literal["no_rfid_no_assignment"] and add structured printer_name/ams_id/tray_id so the UI doesn't string-parse location.
    The color field uses RRGGBB while everywhere else in the codebase uses RRGGBBAA — align.
  • _map_spoolman_spool returns dict while MappedSpoolFields TypedDict in opentag3d.py:25 describes a subset of the same shape. Promote one shared TypedDict and import it from both modules.
  • SpoolmanSlotAssignment model: name the UniqueConstraint (e.g. uq_spoolman_slot_printer_ams_tray), add index=True to printer_id and spoolman_spool_id (the latter is queried by unassign_spoolman_slot's WHERE), and add CheckConstraints on the numeric ranges so direct DB writes can't bypass the Pydantic checks. (ON CONFLICT(cols) is portable Postgres — naming the constraint is hygiene, not portability.)
  • TS getSpoolmanSlotAssignments has an inline anonymous type at client.ts:4321 — hoist to SpoolmanSlotAssignmentRow. getSpoolmanSlotAssignment (singular) is typed
    InventorySpool | null but the unassign endpoint returns {"id": int} for the "spool gone" branch — type mismatch.
  • _extra_locks dict in SpoolmanClient grows unbounded over the process lifetime. Use WeakValueDictionary or evict on release.
  • Inventory-weight load failure logs at main.py:1250, spoolman.py:254, 423 are still logger.debug — the slot-map promotion to warning (H1) missed these parallel sites.
  • opentag3d rgba parse fallback silently encodes solid black on malformed input. Add a one-line logger.warning.

@netscout2001 netscout2001 marked this pull request as draft April 27, 2026 06:48
@netscout2001 netscout2001 force-pushed the feature/spoolman-inventory-ui branch from a302418 to 004fa3b Compare April 27, 2026 13:31
@gitguardian
Copy link
Copy Markdown

gitguardian Bot commented Apr 27, 2026

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@netscout2001 netscout2001 force-pushed the feature/spoolman-inventory-ui branch 3 times, most recently from df61594 to fa96ffb Compare April 27, 2026 13:54
netscout2001 and others added 11 commits April 27, 2026 16:23
…MS deep-link + SpoolBuddy NFC write support (maziggy#1063)

feat(inventory): replace Spoolman iframe with internal inventory UI

When Spoolman is enabled, the Inventory page now uses the same internal
UI (spool list, create/edit modal, archive, delete, weight sync) backed
by a new proxy layer instead of opening an iframe.
  The SSRF guard added in this PR rejected all RFC-1918 private and loopback
  addresses, which breaks Bambuddy's primary deployment topology — Spoolman
  running on the same LAN as Bambuddy (192.168.x.x, 10.x.x.x, 127.0.0.1).
  Users hit "Spoolman URL must not point to a private, loopback, link-local,
  multicast, or unspecified address" on legitimate setups.

  Rescope the guard to block what's actually dangerous in this context:
  cloud metadata endpoints (AWS/Alibaba IMDS), multicast, unspecified,
  non-http(s) schemes, and numeric-encoded IP bypasses. Loopback and
  RFC-1918 ranges are now explicitly permitted.

  Tests:
  - test_ssrf_blocked_schemes_and_addresses updated with refined block list
  - test_ssrf_allows_lan_spoolman_topologies (new) asserts loopback +
    RFC-1918 are accepted so this regression cannot recur silently
  - TestSpoolmanInventorySSRFSpoolBuddyPath parametrize lists trimmed
…ndpoint

The queue_system_command endpoint (reboot/shutdown/restart_daemon/restart_browser)
required SETTINGS_UPDATE, which is blocked for all API keys via _APIKEY_DENIED_PERMISSIONS.
This prevented the SpoolBuddy kiosk (authenticated via API key) from triggering
shutdown/reboot from the display. Changed to INVENTORY_UPDATE, consistent with all
other SpoolBuddy endpoints, and accessible to both API keys and Operator-role users.
…dpoint

The trigger_daemon_update endpoint (POST /devices/{id}/update) required
SETTINGS_UPDATE, which is blocked for all API keys via _APIKEY_DENIED_PERMISSIONS.
This prevented the SpoolBuddy kiosk from triggering software updates from the
display. Changed to INVENTORY_UPDATE, consistent with all other SpoolBuddy
device management endpoints. SSH-level security is handled by the SSH key pair,
not by this HTTP permission gate.
SETTINGS_READ was incorrectly included in _APIKEY_DENIED_PERMISSIONS.
The SpoolBuddy kiosk authenticates via API key and reads settings to sync
the UI language — this was always permitted before the denylist was introduced.
Only write operations (SETTINGS_UPDATE, SETTINGS_BACKUP, SETTINGS_RESTORE)
should be restricted to fully-authenticated users.
ScaleReadingRequest rejected negative weight_grams values (ge=0.0).
The SpoolBuddy scale legitimately reads negative values when the tare
offset is not perfectly calibrated. Changed lower bound to -10000.0g
to accept real-world uncalibrated readings. Upper bound 100000.0g unchanged.
Updated schema validation test to reflect the new accepted range.
Raw ADC values from NAU7802 with default calibration_factor=1.0 can be in
the millions, far exceeding the previous le=100_000.0 constraint. Remove
all ge/le bounds so uncalibrated scale readings are accepted as-is.
SETTINGS_READ is intentionally not denied for API keys — the SpoolBuddy
kiosk reads settings (e.g. UI language) via API key. Move it from the
expected-denied set to the expected-allowed set.
… test coverage

- Add SpoolmanSlotAssignment model and DB migration for (printer, ams, tray) → spool_id mapping
- Slot-assignment CRUD endpoints: verify spool exists before DB commit (H2), re-raise
  non-404 Spoolman errors from GET (H3), return success on 404 after DELETE (H4/CR1)
- DB rollbacks added to all sync batch-persist except blocks (C1/C2/C3) and unlink_spool (H6)
- Slot-map load failures promoted from debug to warning level (H1, 3 sites)
- Sync: add SkippedSpool tracking for no-RFID/no-hint trays (CR3), fix asymmetry in
  sync_all_printers so not-found errors are also reported for non-BL RFID trays
- sync_ams_tray non-BL RFID path: wrap find_or_create_filament in try/except (H5)
- on_ams_change: add isinstance guards for ams_unit/tray_data (CR4); catch ValueError
  from init_spoolman_client so SSRF-blocked URLs log a warning and return cleanly
- delete_printer: explicitly delete SpoolmanSlotAssignment rows (SQLite FK cascade not
  enforced without PRAGMA foreign_keys; pattern matches existing maintenance cleanup)
- S4: move SpoolmanSlotAssignment import to module top-level in spoolman.py and
  spoolman_inventory.py; remove 3 duplicate in-function imports
- Remove dead variables current_tray_uuids/synced_spool_ids across sync routes (S2/CA1)
- Update stale docstrings: clear_location_for_removed_spools (CA3), ON CONFLICT comment
  for assigned_at (CA4), assign_spoolman_slot 404 note (CA5), get_all raw dict format (CA6)
- DB init: add spoolman_slot_assignment to model import list; fix active_print_spoolman
  and spool_usage_history DDL to use is_sqlite() branching (removes AUTOINCREMENT from
  PostgreSQL path)
- Frontend: slot-assignment API client methods, AssignSpoolModal integration,
  PrintersPage AMS slot UI, i18n keys for all 8 locales
- Tests T1-T9: link/unlink Spoolman error paths, slot GET stale-row cleanup, 503
  propagation, sync DB write, hint forwarding, no-RFID skipped entry, non-BL RFID
  error guard, hint uncached path, hint ignored when RFID present, CASCADE delete
…leanup, and test coverage

B1: scrub sensitive fields in settings response for API key callers
B2/I8: reject NaN/Inf on scale weight fields (allow_inf_nan=False)
B3: TOCTOU-safe slot DELETE (double-predicate WHERE clause)
B4: legacy SpoolmanClient methods raise typed exceptions instead of returning None
B5: WeakValueDictionary for _extra_locks to prevent memory leak
B6: _translate_spoolman_errors hardcodes 502 for SpoolmanClientError

I7/I10: update_spool_weight checks local DB before forwarding to Spoolman
I9: SSRF warning broadcast throttled to 60s cooldown
I11: failures list included in bulk-create 207 response body
I12: _apply_price_if_set returns (dict, list[str]) with warnings
I13-I15: _translate_spoolman/spoolbuddy_errors context managers at all call sites
I16: spoolman_tracking wraps use_spool in typed exception handler
I17: spool_weight_warning in response when 250g fallback used

N1: collapse multi-paragraph docstrings to single lines in spoolman.py
N2: remove stale historical comment from auth.py denylist
N3: merge nfc_write_result except blocks; add _translate_spoolbuddy_errors CM
N5/N11: upgrade SSRF logger.debug to logger.warning
N6: add rgba field description to Spoolman schemas
N7: expand MappedSpoolFields TypedDict to 31 fields; annotate return type
N8: named constraints on SpoolmanSlotAssignment (uq/ck_ams_id/ck_tray_id)
N9: extract filterSpoolsByQuery to inventorySearch.ts; use in AssignSpoolModal
N10: SpoolmanSlotAssignmentRow interface in PrintersPage
N12: logger.warning for invalid rgba in opentag3d encoder

T-Gap 1-2: test_settings_api_key_scrubbing (5 tests, loop-based fixture avoids secret scanner hits)
T-Gap 3: test_ssrf_guard (21 tests)
T-Gap 4: test_spoolman_slot_concurrency (3 tests)
T-Gap 5: test_spoolman_slot_ddl (7 tests)
T-Gap 6: test_spoolman_extra_lock (4 tests)
T-Gap 7: AssignSpoolModal Spoolman-mode tests (3 tests)
T-Gap 8: InventoryPageDeepLink Spoolman-mode scenario (2 tests)
T-Gap 9: inventorySearch utility + 11 unit tests

Add .gitguardian.yaml to exclude backend/tests/ as a hard backstop.
- Broadcast tray_uuid in all three WS events (spoolbuddy_tag_matched,
  spoolbuddy_unknown_tag) so the UI knows when a Bambu spool is on the
  scale but not yet linked to a Spoolman entry
- Add PATCH /api/v1/spoolman/inventory/spools/{id}/tag endpoint that
  writes tag_uid or tray_uuid to Spoolman extra.tag via merge_spool_extra;
  tray_uuid takes precedence when both are supplied
- Track unknownTrayUuid in useSpoolBuddyState (state, reducer, event handler)
- Fix SpoolBuddyLayout tagDetected to include unknownTrayUuid so kiosk
  auto-navigates to dashboard on Bambu spool placement
- Make SpoolBuddyDashboard Spoolman-aware: fetch from Spoolman when enabled,
  exclude tray_uuid spools from the untagged list, route handleLinkTagToSpool
  and handleQuickAddToInventory through the Spoolman API in Spoolman mode
- Add 10 backend integration tests and 9 frontend tests covering all new paths
@netscout2001 netscout2001 force-pushed the feature/spoolman-inventory-ui branch from 90cd2ec to cd3d197 Compare April 27, 2026 16:27
…spool link

After linking a tag to a Spoolman spool, the UI stayed on UnknownTagCard
because Spoolman stores the tag as tray_uuid in extra.tag — tag_uid stays
null so tagsEquivalent never matched. Introduce justLinkedSpool state
populated from the InventorySpool returned by the PATCH endpoint, used as
a fallback in the displayedSpool memo until the device re-scans and
sbState.matchedSpool is populated. Also show a success toast on link.

Clear justLinkedSpool when a different tag is detected or the tag-removed
path fires to prevent stale spool data leaking into subsequent scans.
@netscout2001 netscout2001 marked this pull request as ready for review April 27, 2026 16:51
@netscout2001
Copy link
Copy Markdown
Collaborator Author

@maziggy please review only also test PGsql. Thanks

@netscout2001 netscout2001 requested a review from maziggy April 27, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants