| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393 |
- """Spoolman spools get the per-print usage history built-in spools have (#3407).
- Bambuddy charges Spoolman for every print but kept no record of it, so Spoolman
- mode had no usage history: no trend for the stock forecast and no usage list
- on the spool. Each charge now writes a ``spool_usage_history`` row naming the
- Spoolman spool in ``spoolman_spool_id``, with ``spool_id`` NULL so a Spoolman
- id never reads as a built-in spool's (#3406).
- """
- from datetime import timedelta
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select
- from backend.app.models.settings import Settings
- from backend.app.models.spool import Spool
- from backend.app.models.spool_usage_history import SpoolUsageHistory
- from backend.app.utils.local_time import utcnow_naive
- pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
- def _row(spoolman_spool_id=None, spool_id=None, *, grams=10.0, days_ago=1.0, archive_id=None, cost=None):
- return SpoolUsageHistory(
- spool_id=spool_id,
- spoolman_spool_id=spoolman_spool_id,
- weight_used=grams,
- percent_used=1,
- status="completed",
- archive_id=archive_id,
- cost=cost,
- created_at=utcnow_naive() - timedelta(days=days_ago),
- )
- async def _built_in_spool(db) -> Spool:
- spool = Spool(material="PLA", label_weight=1000, core_weight=250, weight_used=0)
- db.add(spool)
- await db.commit()
- await db.refresh(spool)
- return spool
- def _spoolman_with(*spools):
- """Spoolman as Recalculate Costs reads its prices (#3414)."""
- client = MagicMock()
- client.get_all_spools = AsyncMock(return_value=list(spools))
- return patch("backend.app.services.usage_repricing._spoolman_client", AsyncMock(return_value=client))
- async def _rows(db) -> list[SpoolUsageHistory]:
- return list((await db.execute(select(SpoolUsageHistory).execution_options(populate_existing=True))).scalars().all())
- async def test_a_failed_print_records_its_partial_usage_with_its_status(db_session, printer_factory, archive_factory):
- from backend.app.models.active_print_spoolman import ActivePrintSpoolman
- from backend.app.services import spoolman_tracking
- printer = await printer_factory()
- archive = await archive_factory(printer.id, print_name="Bracket", status="aborted")
- db_session.add(ActivePrintSpoolman(printer_id=printer.id, archive_id=archive.id, filament_usage=[], ams_trays={}))
- await db_session.commit()
- async def _partial(printer_id, tracking, *, last_layer_num=None, last_progress=None, cost_out=None):
- cost_out.add(40.0, {"id": 5, "price": 20.0, "filament": {"weight": 1000}}, "slot 1", spool_id=5)
- with patch.object(spoolman_tracking, "_report_partial_usage", _partial):
- await spoolman_tracking.cleanup_tracking(printer.id, archive.id, db_session, status="aborted")
- ((row,),) = [await _rows(db_session)]
- assert (row.spool_id, row.spoolman_spool_id, row.weight_used, row.status, row.print_name) == (
- None,
- 5,
- 40.0,
- "aborted",
- "Bracket",
- )
- assert (row.percent_used, row.cost) == (4, 0.8)
- async def test_an_unpriced_spool_is_recorded_without_a_price(db_session, printer_factory, archive_factory):
- """As completion leaves the archive's cost alone for it, so a recalculation
- keeps that figure too (see the recalculation test below)."""
- from backend.app.services.spoolman_tracking import _PrintCost, _record_usage_history
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- cost = _PrintCost()
- cost.add(100.0, {"id": 6, "filament": {"weight": 1000}}, "slot 2", spool_id=6)
- await _record_usage_history(db_session, printer.id, archive.id, "completed", cost)
- ((row,),) = [await _rows(db_session)]
- assert (row.weight_used, row.cost) == (100.0, None)
- async def test_recalculating_keeps_the_cost_of_a_print_from_unpriced_spools(
- async_client: AsyncClient, db_session, printer_factory, archive_factory
- ):
- """The archive keeps what it was priced at (here the catalogue's 30/kg),
- not the 25/kg default the unpriced row would otherwise stand for."""
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- db_session.add(Settings(key="default_filament_cost", value="25"))
- printer = await printer_factory()
- archive = await archive_factory(printer.id, status="completed", cost=3.0)
- archive.filament_used_grams = 100.0
- db_session.add(_row(spoolman_spool_id=41, grams=100.0, archive_id=archive.id, cost=None))
- await db_session.commit()
- with _spoolman_with({"id": 41, "initial_weight": 1000, "filament": {"weight": 1000}}):
- assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
- assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["cost"] == 3.0
- async def test_the_global_usage_list_is_one_inventorys(async_client: AsyncClient, db_session):
- spool = await _built_in_spool(db_session)
- db_session.add_all([_row(spool_id=spool.id), _row(spoolman_spool_id=spool.id)])
- await db_session.commit()
- built_in = (await async_client.get("/api/v1/inventory/usage")).json()
- spoolman = (await async_client.get("/api/v1/inventory/usage", params={"spoolman": "true"})).json()
- assert [(r["spool_id"], r["spoolman_spool_id"]) for r in built_in] == [(spool.id, None)]
- assert [(r["spool_id"], r["spoolman_spool_id"]) for r in spoolman] == [(None, spool.id)]
- async def test_a_spoolman_spool_lists_and_clears_its_own_usage(async_client: AsyncClient, db_session):
- """Never a built-in spool's that shares its id."""
- spool = await _built_in_spool(db_session)
- db_session.add_all([_row(spoolman_spool_id=spool.id, grams=12.0), _row(spool_id=spool.id, grams=99.0)])
- await db_session.commit()
- url = f"/api/v1/spoolman/inventory/spools/{spool.id}/usage"
- listed = (await async_client.get(url)).json()
- assert [r["weight_used"] for r in listed] == [12.0]
- assert (await async_client.delete(url)).status_code == 200
- assert [(r.spool_id, r.weight_used) for r in await _rows(db_session)] == [(spool.id, 99.0)]
- async def test_deleting_the_spoolman_spool_keeps_its_usage_without_its_id(async_client: AsyncClient, db_session):
- """Spoolman can give the id to a new spool, which must not inherit it."""
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
- db_session.add_all([_row(spoolman_spool_id=5), _row(spoolman_spool_id=6)])
- await db_session.commit()
- client = MagicMock()
- client.delete_spool = AsyncMock()
- with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)):
- response = await async_client.delete("/api/v1/spoolman/inventory/spools/5")
- assert response.status_code == 200, response.text
- assert sorted((r.spoolman_spool_id or 0) for r in await _rows(db_session)) == [0, 6]
- async def test_the_stock_alerts_use_the_spoolman_rows_in_spoolman_mode(db_session):
- """And with them the trend, as built-in spools have it."""
- from backend.app.services.print_scheduler import PrintScheduler
- from backend.app.services.stock_forecast import UsageRecord, history_rate
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- days = (30, 20, 10)
- db_session.add_all([_row(spoolman_spool_id=7, grams=30.0, days_ago=d) for d in days])
- # A built-in spool 7, from before the switch: not the Spoolman spool's.
- db_session.add_all([_row(spool_id=None, grams=500.0, days_ago=d) for d in (5, 4)])
- await db_session.commit()
- spoolman = MagicMock()
- spoolman.get_all_spools = AsyncMock(
- return_value=[
- {
- "id": 7,
- "remaining_weight": 900.0,
- "used_weight": 100.0,
- "registered": (utcnow_naive() - timedelta(days=100)).isoformat() + "Z",
- "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
- }
- ]
- )
- with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
- ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
- trend = history_rate([UsageRecord(utcnow_naive() - timedelta(days=d), 30.0) for d in days], utcnow_naive())
- assert forecast.daily_rate_g == pytest.approx(trend[0], rel=1e-3)
- async def test_recalculating_costs_rebuilds_a_spoolman_archive_from_its_rows(
- async_client: AsyncClient, db_session, printer_factory, archive_factory
- ):
- """The rows carry what the charges were priced at; at the spool's price
- unchanged, the figure stays."""
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- db_session.add(Settings(key="default_filament_cost", value="25"))
- printer = await printer_factory()
- archive = await archive_factory(printer.id, status="completed", cost=4.0)
- archive.filament_used_grams = 100.0
- db_session.add(_row(spoolman_spool_id=41, grams=100.0, archive_id=archive.id, cost=4.0))
- await db_session.commit()
- with _spoolman_with({"id": 41, "price": 40.0, "initial_weight": 1000, "filament": {"weight": 1000}}):
- assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
- assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["cost"] == 4.0
- async def test_a_backup_restores_the_spoolman_spool(db_session):
- from backend.app.services.github_restore import GitHubRestoreService, _CategoryTally
- usage = {
- "usage_history": [
- {
- "id": 1,
- "spool_id": None,
- "spoolman_spool_id": 41,
- "weight_used": 12.5,
- "created_at": "2026-01-06 09:00:00",
- },
- {
- "id": 2,
- "spool_id": None,
- "spoolman_spool_id": True,
- "weight_used": 1.0,
- "created_at": "2026-01-06 10:00:00",
- },
- ]
- }
- tally = _CategoryTally()
- await GitHubRestoreService()._restore_spool_usage(db_session, usage, tally, {}, {})
- await db_session.commit()
- assert sorted((r.spoolman_spool_id or 0, r.weight_used) for r in await _rows(db_session)) == [(0, 1.0), (41, 12.5)]
- # Two Spoolman spools charged the same at the same moment are two rows.
- twins = {
- "usage_history": [
- {"id": 3, "spoolman_spool_id": 50, "weight_used": 7.0, "created_at": "2026-01-07 09:00:00"},
- {"id": 4, "spoolman_spool_id": 51, "weight_used": 7.0, "created_at": "2026-01-07 09:00:00"},
- ]
- }
- await GitHubRestoreService()._restore_spool_usage(db_session, twins, _CategoryTally(), {}, {})
- await db_session.commit()
- assert sorted(r.spoolman_spool_id or 0 for r in await _rows(db_session) if r.weight_used == 7.0) == [50, 51]
- usage["usage_history"] += twins["usage_history"]
- # Restored again: recognised, not doubled.
- await GitHubRestoreService()._restore_spool_usage(db_session, usage, _CategoryTally(), {}, {})
- await db_session.commit()
- assert len(await _rows(db_session)) == 4
- async def test_a_backup_without_built_in_spools_restores_the_usage(db_session):
- """A Spoolman-mode backup has no spools/inventory.json to go with it."""
- from backend.app.services.github_restore import GitHubRestoreService, _CategoryTally
- usage = {"usage_history": [{"id": 1, "spoolman_spool_id": 41, "weight_used": 5.0, "created_at": "2026-01-06"}]}
- await GitHubRestoreService()._restore_spools(db_session, None, usage, False, _CategoryTally(), {})
- await db_session.commit()
- assert [r.spoolman_spool_id for r in await _rows(db_session)] == [41]
- async def test_a_backup_carries_the_spoolman_spool(db_session):
- from backend.app.services.github_backup import GitHubBackupService
- db_session.add(_row(spoolman_spool_id=41))
- await db_session.commit()
- files: dict = {}
- await GitHubBackupService()._collect_spools(db_session, files)
- assert [u["spoolman_spool_id"] for u in files["spools/usage_history.json"]["usage_history"]] == [41]
- async def test_each_inventory_reads_its_own_rows_up_to_the_limit(db_session, monkeypatch):
- """The read is capped; newer rows of the other inventory must not use it up."""
- from backend.app.services import print_scheduler
- built_in = await _built_in_spool(db_session)
- db_session.add(_row(spool_id=built_in.id, days_ago=5))
- db_session.add_all([_row(spoolman_spool_id=9, days_ago=d) for d in (1, 2)])
- await db_session.commit()
- monkeypatch.setattr(print_scheduler, "_STOCK_FORECAST_HISTORY_LIMIT", 1)
- scheduler = print_scheduler.PrintScheduler()
- assert list(await scheduler._stock_usage_history(db_session)) == [built_in.id]
- assert list(await scheduler._stock_usage_history(db_session, spoolman=True)) == [9]
- async def test_the_spoolman_stock_alerts_spread_a_spool_from_its_first_use(db_session):
- """Registered 120 days ago, first printed with 21 days ago, last 2 days ago."""
- from backend.app.services.print_scheduler import PrintScheduler
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- await db_session.commit()
- def _iso(days):
- return (utcnow_naive() - timedelta(days=days)).isoformat() + "Z"
- spoolman = MagicMock()
- spoolman.get_all_spools = AsyncMock(
- return_value=[
- {
- "id": 3,
- "remaining_weight": 900.0,
- "used_weight": 100.0,
- "registered": _iso(120),
- "first_used": _iso(21),
- "last_used": _iso(2),
- "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
- }
- ]
- )
- with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
- ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
- assert forecast.daily_rate_g == pytest.approx(100 / 21, rel=1e-3)
- async def test_the_built_in_stock_alerts_end_a_spool_at_its_last_use(db_session):
- """Created 120 days ago, last used 100 days ago: nothing in the last 90."""
- from backend.app.services.print_scheduler import PrintScheduler
- spool = await _built_in_spool(db_session)
- spool.weight_used = 300.0
- spool.created_at = utcnow_naive() - timedelta(days=120)
- spool.last_used = utcnow_naive() - timedelta(days=100)
- await db_session.commit()
- ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
- assert forecast.daily_rate_g == 0
- async def test_the_spoolman_stock_alerts_end_a_spool_at_its_last_use(db_session):
- """Used from 150 to 60 days ago: 30 of its 90 days of use are in the window,
- so 30 of its 90 g. Same as the panel's test."""
- from backend.app.services.print_scheduler import PrintScheduler
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- await db_session.commit()
- def _iso(days):
- return (utcnow_naive() - timedelta(days=days)).isoformat() + "Z"
- spoolman = MagicMock()
- spoolman.get_all_spools = AsyncMock(
- return_value=[
- {
- "id": 4,
- "remaining_weight": 910.0,
- "used_weight": 90.0,
- "registered": _iso(200),
- "first_used": _iso(150),
- "last_used": _iso(60),
- "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
- }
- ]
- )
- with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
- ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
- assert forecast.daily_rate_g == pytest.approx(30 / 90, rel=1e-3)
- async def test_rows_of_an_earlier_spool_with_the_same_id_are_not_its_own(db_session):
- """Spoolman handed id 6 to a new spool, registered 15 days ago; rows from
- before that are the deleted spool's."""
- from backend.app.services.print_scheduler import PrintScheduler
- from backend.app.services.stock_forecast import UsageRecord, history_rate
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- db_session.add_all([_row(spoolman_spool_id=6, grams=30.0, days_ago=d) for d in (40, 30, 10, 5)])
- await db_session.commit()
- spoolman = MagicMock()
- spoolman.get_all_spools = AsyncMock(
- return_value=[
- {
- "id": 6,
- "remaining_weight": 940.0,
- "used_weight": 60.0,
- "registered": (utcnow_naive() - timedelta(days=15)).isoformat() + "Z",
- "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
- }
- ]
- )
- with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
- ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
- own = history_rate([UsageRecord(utcnow_naive() - timedelta(days=d), 30.0) for d in (10, 5)], utcnow_naive())
- assert forecast.daily_rate_g == pytest.approx(own[0], rel=1e-3)
|