Files
bambuddy/backend/tests/unit/test_ams_slot_location_cleanup.py
maziggy 537b4d2509 Stop offering AMS slots as places to store a spool
The Storage Location dropdown listed entries like "H2D-1 - AMS A1" next to
    real locations, and they could not be got rid of.

    They were never locations. Bambuddy used to record which slot a spool was
    loaded into by writing that string into Spoolman's location field, and the
    writer went away when Storage Location became something the user picks --
    but the strings stayed on people's Spoolman spools, and the location sync
    imports every distinct one it finds, so they have been coming back in
    through the front door ever since. A printer slot is where a spool is
    loaded, not where it is put away, and slot assignments already track the
    first.

    Deleting one by hand did not work either, which is what made this a dead
    end rather than an annoyance: the delete route refuses a location that has
    spools, and in Spoolman mode it counts them by matching that same string,
    so every marker still sitting on a loaded spool answered 409 -- and the two
    that were empty were back on the next sync a minute later.

    The import now skips them and a one-shot migration clears the ones already
    in the catalogue. The shape is defined once and used by both: an optional
    printer-name prefix followed by AMS A1, AMS-HT A1 or External Spool, which
    is exactly what convert_ams_slot_to_location produced. It stays narrow on
    purpose -- "AMS Drybox" and "Spare AMS trays" are somebody's shelf, and
    anything the filter swallowed would be a place they could no longer file a
    spool under -- so both directions are pinned by tests.

    A row is only removed when no spool in this database points at it, by id or
    by legacy free-text name, so an internal-mode user who has deliberately
    filed spools under such a name keeps it. Spools in Spoolman are neither
    consulted nor touched: their location strings are the user's data on the
    user's server, and one that still reads "H2D-1 - AMS A1" in the inventory
    list is telling the truth about what Spoolman holds. It simply stops being
    offered as a destination.

    Verified on a live Postgres instance carrying the reported symptom: 13
    locations down to 3, all ten markers removed, the two real shelves and one
    hand-typed Spoolman name left alone.
2026-08-23 15:17:09 +02:00

149 lines
4.7 KiB
Python

"""Cleanup of AMS slot markers imported into the storage-location catalogue.
Bambuddy used to write the slot a spool was loaded into -- "<printer> - AMS A1"
-- into Spoolman's ``location`` field, and the location sync then imported every
distinct one as a storage location. ``_migrate_drop_ams_slot_locations`` clears
the rows that already landed; the import side is covered in
``test_location_service.py``.
"""
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
from backend.app.core.database import Base, _migrate_drop_ams_slot_locations
from backend.app.models.location import Location
from backend.app.models.spool import Spool
from backend.app.services.location_service import assign_location_name
FLAG = "_cleanup_ams_slot_locations_done"
@pytest.fixture
async def engine(tmp_path):
eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
yield eng
finally:
await eng.dispose()
def _location(name: str) -> Location:
loc = Location()
assign_location_name(loc, name)
return loc
async def _names(db) -> set[str]:
return {r[0] for r in (await db.execute(text("SELECT name FROM locations"))).fetchall()}
async def _run(engine):
async with engine.begin() as conn:
await _migrate_drop_ams_slot_locations(conn)
@pytest.mark.asyncio
async def test_removes_the_slot_markers_and_keeps_real_locations(engine):
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
db.add_all(
[
_location("H2D-1 - AMS A1"),
_location("H2D-1 - AMS C3"),
_location("X1C-2 - AMS-HT A1"),
_location("P1S - External Spool"),
_location("Drybox 1"),
_location("Shelf A"),
]
)
await db.commit()
await _run(engine)
async with sm() as db:
assert await _names(db) == {"Drybox 1", "Shelf A"}
@pytest.mark.asyncio
async def test_keeps_a_marker_a_spool_is_actually_filed_under(engine):
"""Deleting it would strand the spool's location, and someone who has
deliberately filed spools under that name meant it."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
loc = _location("H2D-1 - AMS A1")
db.add(loc)
await db.flush()
db.add(
Spool(
material="PLA",
label_weight=1000,
location_id=loc.id,
storage_location="H2D-1 - AMS A1",
)
)
await db.commit()
await _run(engine)
async with sm() as db:
assert await _names(db) == {"H2D-1 - AMS A1"}
@pytest.mark.asyncio
async def test_keeps_a_marker_a_legacy_free_text_spool_still_names(engine):
"""Rows predating the location catalogue carry the name without the FK, and
the rename cascade still matches them on it."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
db.add(_location("H2D-1 - AMS A1"))
await db.flush()
# Whitespace and case around the name are the legacy shape the rename
# cascade already has to cope with, so the guard has to match it too.
db.add(Spool(material="PLA", label_weight=1000, storage_location=" h2d-1 - ams a1 "))
await db.commit()
await _run(engine)
async with sm() as db:
assert await _names(db) == {"H2D-1 - AMS A1"}
@pytest.mark.asyncio
async def test_runs_exactly_once(engine):
"""A location the user creates afterwards is theirs, whatever it is named."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
db.add(_location("H2D-1 - AMS A1"))
await db.commit()
await _run(engine)
async with sm() as db:
db.add(_location("H2D-1 - AMS B2"))
await db.commit()
await _run(engine)
async with sm() as db:
assert await _names(db) == {"H2D-1 - AMS B2"}
@pytest.mark.asyncio
async def test_marks_itself_done_on_an_install_with_nothing_to_remove(engine):
"""Otherwise the whole catalogue is rescanned on every boot for ever."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
db.add(_location("Drybox 1"))
await db.commit()
await _run(engine)
async with sm() as db:
done = (await db.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": FLAG})).scalar_one_or_none()
assert done == "true"
assert await _names(db) == {"Drybox 1"}