fix(inventory): stop popping the unknown-tag modal for slots with no RFID

The 7cb905a follow-up mounted the global unknown-tag modal listener, which
  turned an existing always-on broadcast for no-tag slots from a silent no-op
  into a perpetual popup loop — every push for a slot with a generic
  non-RFID spool (or zero-filled tag) re-prompted, and confirming each one
  created a fresh ghost spool with an empty tag.

  - main.py on_ams_change: drop the no-tag else-branch broadcast. No identity,
    no prompt; the slot stays unassigned until a real tag is read.
  - inventory.py + spoolman.py /spools/from-slot: 400 when the slot has no
    usable tag_uid / tray_uuid so stale frontends can't recreate the ghost
    spool by re-confirming a queued prompt.
  - test_inventory_from_slot_no_tag: lock the guard in (zero-filled + empty
    string).
This commit is contained in:
maziggy
2026-06-25 09:57:15 +02:00
parent 261c376d1f
commit 8a26e7d753
4 changed files with 87 additions and 13 deletions
+8
View File
@@ -2457,6 +2457,14 @@ async def create_spool_from_slot(
if not tray or not tray.get("tray_type"):
raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
# Guard against ghost-spool creation: a slot without any RFID tag has no
# stable identity, so creating an inventory row would just duplicate on
# every confirm and never re-link to the physical spool.
from backend.app.services.spool_tag_matcher import is_valid_tag
if not is_valid_tag(tray.get("tag_uid", ""), tray.get("tray_uuid", "")):
raise HTTPException(status_code=400, detail="Slot has no RFID tag")
spool = await create_spool_from_tray(db, tray)
await auto_assign_spool(
req.printer_id,
+7
View File
@@ -1207,6 +1207,13 @@ async def create_spool_from_slot(
if not tray:
raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
# Same ghost-spool guard as the inventory route: no tag → no stable
# identity → confirm would just create a fresh Spoolman row per push.
from backend.app.services.spool_tag_matcher import is_valid_tag
if not is_valid_tag(tray.tag_uid or "", tray.tray_uuid or ""):
raise HTTPException(status_code=400, detail="Slot has no RFID tag")
sync_result = await client.sync_ams_tray(
tray,
printer.name,
+3 -13
View File
@@ -1819,19 +1819,9 @@ async def on_ams_change(printer_id: int, ams_data: list):
tray_sub_brands=tray.get("tray_sub_brands"),
tray_count=len(ams_unit.get("tray", [])),
)
else:
# No tag at all — let user choose from inventory
await _broadcast_unknown_tag(
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray_id,
tag_uid="",
tray_uuid="",
tray_type=tray.get("tray_type"),
tray_color=tray.get("tray_color"),
tray_sub_brands=tray.get("tray_sub_brands"),
tray_count=len(ams_unit.get("tray", [])),
)
# No-tag slots (generic non-RFID filament) are left alone:
# nothing to identify, prompting "+ Add" would just create
# ghost spools with empty tags on every confirm.
except Exception as e:
logger.warning("RFID spool auto-assign failed: %s", e, exc_info=True)
@@ -0,0 +1,69 @@
"""No-tag guard on POST /api/v1/inventory/spools/from-slot.
A slot that has tray_type set but no RFID tag (generic third-party PLA the
user manually configured on the printer panel) has no stable identity, so
auto-creating an inventory row for it would just duplicate on every confirm
without ever re-linking to the physical spool. Verify the endpoint refuses
that case with 400.
"""
from unittest.mock import MagicMock, patch
import pytest
from httpx import AsyncClient
def _mock_status_for_tray(ams_id: int, tray_id: int, tray: dict) -> MagicMock:
status = MagicMock()
status.raw_data = {"ams": {"ams": [{"id": ams_id, "tray": [{"id": tray_id, **tray}]}]}}
return status
@pytest.mark.asyncio
@pytest.mark.integration
async def test_from_slot_rejects_no_tag(async_client: AsyncClient, printer_factory):
printer = await printer_factory(name="X1C-no-tag")
no_tag_tray = {
"tray_type": "PLA",
"tray_color": "FF0000FF",
"tag_uid": "0000000000000000",
"tray_uuid": "00000000000000000000000000000000",
}
with patch(
"backend.app.services.printer_manager.printer_manager.get_status",
return_value=_mock_status_for_tray(0, 1, no_tag_tray),
):
resp = await async_client.post(
"/api/v1/inventory/spools/from-slot",
json={"printer_id": printer.id, "ams_id": 0, "tray_id": 1},
)
assert resp.status_code == 400
assert "RFID tag" in resp.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_from_slot_rejects_empty_tag_strings(async_client: AsyncClient, printer_factory):
"""Empty strings (not zero-filled) must also be refused."""
printer = await printer_factory(name="X1C-empty-tag")
empty_tag_tray = {
"tray_type": "PETG",
"tray_color": "00FF00FF",
"tag_uid": "",
"tray_uuid": "",
}
with patch(
"backend.app.services.printer_manager.printer_manager.get_status",
return_value=_mock_status_for_tray(0, 2, empty_tag_tray),
):
resp = await async_client.post(
"/api/v1/inventory/spools/from-slot",
json={"printer_id": printer.id, "ams_id": 0, "tray_id": 2},
)
assert resp.status_code == 400