test_spoolman_usage_history_3407.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. """Spoolman spools get the per-print usage history built-in spools have (#3407).
  2. Bambuddy charges Spoolman for every print but kept no record of it, so Spoolman
  3. mode had no usage history: no trend for the stock forecast and no usage list
  4. on the spool. Each charge now writes a ``spool_usage_history`` row naming the
  5. Spoolman spool in ``spoolman_spool_id``, with ``spool_id`` NULL so a Spoolman
  6. id never reads as a built-in spool's (#3406).
  7. """
  8. from datetime import timedelta
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from httpx import AsyncClient
  12. from sqlalchemy import select
  13. from backend.app.models.settings import Settings
  14. from backend.app.models.spool import Spool
  15. from backend.app.models.spool_usage_history import SpoolUsageHistory
  16. from backend.app.utils.local_time import utcnow_naive
  17. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  18. def _row(spoolman_spool_id=None, spool_id=None, *, grams=10.0, days_ago=1.0, archive_id=None, cost=None):
  19. return SpoolUsageHistory(
  20. spool_id=spool_id,
  21. spoolman_spool_id=spoolman_spool_id,
  22. weight_used=grams,
  23. percent_used=1,
  24. status="completed",
  25. archive_id=archive_id,
  26. cost=cost,
  27. created_at=utcnow_naive() - timedelta(days=days_ago),
  28. )
  29. async def _built_in_spool(db) -> Spool:
  30. spool = Spool(material="PLA", label_weight=1000, core_weight=250, weight_used=0)
  31. db.add(spool)
  32. await db.commit()
  33. await db.refresh(spool)
  34. return spool
  35. def _spoolman_with(*spools):
  36. """Spoolman as Recalculate Costs reads its prices (#3414)."""
  37. client = MagicMock()
  38. client.get_all_spools = AsyncMock(return_value=list(spools))
  39. return patch("backend.app.services.usage_repricing._spoolman_client", AsyncMock(return_value=client))
  40. async def _rows(db) -> list[SpoolUsageHistory]:
  41. return list((await db.execute(select(SpoolUsageHistory).execution_options(populate_existing=True))).scalars().all())
  42. async def test_a_failed_print_records_its_partial_usage_with_its_status(db_session, printer_factory, archive_factory):
  43. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  44. from backend.app.services import spoolman_tracking
  45. printer = await printer_factory()
  46. archive = await archive_factory(printer.id, print_name="Bracket", status="aborted")
  47. db_session.add(ActivePrintSpoolman(printer_id=printer.id, archive_id=archive.id, filament_usage=[], ams_trays={}))
  48. await db_session.commit()
  49. async def _partial(printer_id, tracking, *, last_layer_num=None, last_progress=None, cost_out=None):
  50. cost_out.add(40.0, {"id": 5, "price": 20.0, "filament": {"weight": 1000}}, "slot 1", spool_id=5)
  51. with patch.object(spoolman_tracking, "_report_partial_usage", _partial):
  52. await spoolman_tracking.cleanup_tracking(printer.id, archive.id, db_session, status="aborted")
  53. ((row,),) = [await _rows(db_session)]
  54. assert (row.spool_id, row.spoolman_spool_id, row.weight_used, row.status, row.print_name) == (
  55. None,
  56. 5,
  57. 40.0,
  58. "aborted",
  59. "Bracket",
  60. )
  61. assert (row.percent_used, row.cost) == (4, 0.8)
  62. async def test_an_unpriced_spool_is_recorded_without_a_price(db_session, printer_factory, archive_factory):
  63. """As completion leaves the archive's cost alone for it, so a recalculation
  64. keeps that figure too (see the recalculation test below)."""
  65. from backend.app.services.spoolman_tracking import _PrintCost, _record_usage_history
  66. printer = await printer_factory()
  67. archive = await archive_factory(printer.id)
  68. cost = _PrintCost()
  69. cost.add(100.0, {"id": 6, "filament": {"weight": 1000}}, "slot 2", spool_id=6)
  70. await _record_usage_history(db_session, printer.id, archive.id, "completed", cost)
  71. ((row,),) = [await _rows(db_session)]
  72. assert (row.weight_used, row.cost) == (100.0, None)
  73. async def test_recalculating_keeps_the_cost_of_a_print_from_unpriced_spools(
  74. async_client: AsyncClient, db_session, printer_factory, archive_factory
  75. ):
  76. """The archive keeps what it was priced at (here the catalogue's 30/kg),
  77. not the 25/kg default the unpriced row would otherwise stand for."""
  78. db_session.add(Settings(key="spoolman_enabled", value="true"))
  79. db_session.add(Settings(key="default_filament_cost", value="25"))
  80. printer = await printer_factory()
  81. archive = await archive_factory(printer.id, status="completed", cost=3.0)
  82. archive.filament_used_grams = 100.0
  83. db_session.add(_row(spoolman_spool_id=41, grams=100.0, archive_id=archive.id, cost=None))
  84. await db_session.commit()
  85. with _spoolman_with({"id": 41, "initial_weight": 1000, "filament": {"weight": 1000}}):
  86. assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
  87. assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["cost"] == 3.0
  88. async def test_the_global_usage_list_is_one_inventorys(async_client: AsyncClient, db_session):
  89. spool = await _built_in_spool(db_session)
  90. db_session.add_all([_row(spool_id=spool.id), _row(spoolman_spool_id=spool.id)])
  91. await db_session.commit()
  92. built_in = (await async_client.get("/api/v1/inventory/usage")).json()
  93. spoolman = (await async_client.get("/api/v1/inventory/usage", params={"spoolman": "true"})).json()
  94. assert [(r["spool_id"], r["spoolman_spool_id"]) for r in built_in] == [(spool.id, None)]
  95. assert [(r["spool_id"], r["spoolman_spool_id"]) for r in spoolman] == [(None, spool.id)]
  96. async def test_a_spoolman_spool_lists_and_clears_its_own_usage(async_client: AsyncClient, db_session):
  97. """Never a built-in spool's that shares its id."""
  98. spool = await _built_in_spool(db_session)
  99. db_session.add_all([_row(spoolman_spool_id=spool.id, grams=12.0), _row(spool_id=spool.id, grams=99.0)])
  100. await db_session.commit()
  101. url = f"/api/v1/spoolman/inventory/spools/{spool.id}/usage"
  102. listed = (await async_client.get(url)).json()
  103. assert [r["weight_used"] for r in listed] == [12.0]
  104. assert (await async_client.delete(url)).status_code == 200
  105. assert [(r.spool_id, r.weight_used) for r in await _rows(db_session)] == [(spool.id, 99.0)]
  106. async def test_deleting_the_spoolman_spool_keeps_its_usage_without_its_id(async_client: AsyncClient, db_session):
  107. """Spoolman can give the id to a new spool, which must not inherit it."""
  108. db_session.add(Settings(key="spoolman_enabled", value="true"))
  109. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  110. db_session.add_all([_row(spoolman_spool_id=5), _row(spoolman_spool_id=6)])
  111. await db_session.commit()
  112. client = MagicMock()
  113. client.delete_spool = AsyncMock()
  114. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)):
  115. response = await async_client.delete("/api/v1/spoolman/inventory/spools/5")
  116. assert response.status_code == 200, response.text
  117. assert sorted((r.spoolman_spool_id or 0) for r in await _rows(db_session)) == [0, 6]
  118. async def test_the_stock_alerts_use_the_spoolman_rows_in_spoolman_mode(db_session):
  119. """And with them the trend, as built-in spools have it."""
  120. from backend.app.services.print_scheduler import PrintScheduler
  121. from backend.app.services.stock_forecast import UsageRecord, history_rate
  122. db_session.add(Settings(key="spoolman_enabled", value="true"))
  123. days = (30, 20, 10)
  124. db_session.add_all([_row(spoolman_spool_id=7, grams=30.0, days_ago=d) for d in days])
  125. # A built-in spool 7, from before the switch: not the Spoolman spool's.
  126. db_session.add_all([_row(spool_id=None, grams=500.0, days_ago=d) for d in (5, 4)])
  127. await db_session.commit()
  128. spoolman = MagicMock()
  129. spoolman.get_all_spools = AsyncMock(
  130. return_value=[
  131. {
  132. "id": 7,
  133. "remaining_weight": 900.0,
  134. "used_weight": 100.0,
  135. "registered": (utcnow_naive() - timedelta(days=100)).isoformat() + "Z",
  136. "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
  137. }
  138. ]
  139. )
  140. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
  141. ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
  142. trend = history_rate([UsageRecord(utcnow_naive() - timedelta(days=d), 30.0) for d in days], utcnow_naive())
  143. assert forecast.daily_rate_g == pytest.approx(trend[0], rel=1e-3)
  144. async def test_recalculating_costs_rebuilds_a_spoolman_archive_from_its_rows(
  145. async_client: AsyncClient, db_session, printer_factory, archive_factory
  146. ):
  147. """The rows carry what the charges were priced at; at the spool's price
  148. unchanged, the figure stays."""
  149. db_session.add(Settings(key="spoolman_enabled", value="true"))
  150. db_session.add(Settings(key="default_filament_cost", value="25"))
  151. printer = await printer_factory()
  152. archive = await archive_factory(printer.id, status="completed", cost=4.0)
  153. archive.filament_used_grams = 100.0
  154. db_session.add(_row(spoolman_spool_id=41, grams=100.0, archive_id=archive.id, cost=4.0))
  155. await db_session.commit()
  156. with _spoolman_with({"id": 41, "price": 40.0, "initial_weight": 1000, "filament": {"weight": 1000}}):
  157. assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
  158. assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["cost"] == 4.0
  159. async def test_a_backup_restores_the_spoolman_spool(db_session):
  160. from backend.app.services.github_restore import GitHubRestoreService, _CategoryTally
  161. usage = {
  162. "usage_history": [
  163. {
  164. "id": 1,
  165. "spool_id": None,
  166. "spoolman_spool_id": 41,
  167. "weight_used": 12.5,
  168. "created_at": "2026-01-06 09:00:00",
  169. },
  170. {
  171. "id": 2,
  172. "spool_id": None,
  173. "spoolman_spool_id": True,
  174. "weight_used": 1.0,
  175. "created_at": "2026-01-06 10:00:00",
  176. },
  177. ]
  178. }
  179. tally = _CategoryTally()
  180. await GitHubRestoreService()._restore_spool_usage(db_session, usage, tally, {}, {})
  181. await db_session.commit()
  182. assert sorted((r.spoolman_spool_id or 0, r.weight_used) for r in await _rows(db_session)) == [(0, 1.0), (41, 12.5)]
  183. # Two Spoolman spools charged the same at the same moment are two rows.
  184. twins = {
  185. "usage_history": [
  186. {"id": 3, "spoolman_spool_id": 50, "weight_used": 7.0, "created_at": "2026-01-07 09:00:00"},
  187. {"id": 4, "spoolman_spool_id": 51, "weight_used": 7.0, "created_at": "2026-01-07 09:00:00"},
  188. ]
  189. }
  190. await GitHubRestoreService()._restore_spool_usage(db_session, twins, _CategoryTally(), {}, {})
  191. await db_session.commit()
  192. assert sorted(r.spoolman_spool_id or 0 for r in await _rows(db_session) if r.weight_used == 7.0) == [50, 51]
  193. usage["usage_history"] += twins["usage_history"]
  194. # Restored again: recognised, not doubled.
  195. await GitHubRestoreService()._restore_spool_usage(db_session, usage, _CategoryTally(), {}, {})
  196. await db_session.commit()
  197. assert len(await _rows(db_session)) == 4
  198. async def test_a_backup_without_built_in_spools_restores_the_usage(db_session):
  199. """A Spoolman-mode backup has no spools/inventory.json to go with it."""
  200. from backend.app.services.github_restore import GitHubRestoreService, _CategoryTally
  201. usage = {"usage_history": [{"id": 1, "spoolman_spool_id": 41, "weight_used": 5.0, "created_at": "2026-01-06"}]}
  202. await GitHubRestoreService()._restore_spools(db_session, None, usage, False, _CategoryTally(), {})
  203. await db_session.commit()
  204. assert [r.spoolman_spool_id for r in await _rows(db_session)] == [41]
  205. async def test_a_backup_carries_the_spoolman_spool(db_session):
  206. from backend.app.services.github_backup import GitHubBackupService
  207. db_session.add(_row(spoolman_spool_id=41))
  208. await db_session.commit()
  209. files: dict = {}
  210. await GitHubBackupService()._collect_spools(db_session, files)
  211. assert [u["spoolman_spool_id"] for u in files["spools/usage_history.json"]["usage_history"]] == [41]
  212. async def test_each_inventory_reads_its_own_rows_up_to_the_limit(db_session, monkeypatch):
  213. """The read is capped; newer rows of the other inventory must not use it up."""
  214. from backend.app.services import print_scheduler
  215. built_in = await _built_in_spool(db_session)
  216. db_session.add(_row(spool_id=built_in.id, days_ago=5))
  217. db_session.add_all([_row(spoolman_spool_id=9, days_ago=d) for d in (1, 2)])
  218. await db_session.commit()
  219. monkeypatch.setattr(print_scheduler, "_STOCK_FORECAST_HISTORY_LIMIT", 1)
  220. scheduler = print_scheduler.PrintScheduler()
  221. assert list(await scheduler._stock_usage_history(db_session)) == [built_in.id]
  222. assert list(await scheduler._stock_usage_history(db_session, spoolman=True)) == [9]
  223. async def test_the_spoolman_stock_alerts_spread_a_spool_from_its_first_use(db_session):
  224. """Registered 120 days ago, first printed with 21 days ago, last 2 days ago."""
  225. from backend.app.services.print_scheduler import PrintScheduler
  226. db_session.add(Settings(key="spoolman_enabled", value="true"))
  227. await db_session.commit()
  228. def _iso(days):
  229. return (utcnow_naive() - timedelta(days=days)).isoformat() + "Z"
  230. spoolman = MagicMock()
  231. spoolman.get_all_spools = AsyncMock(
  232. return_value=[
  233. {
  234. "id": 3,
  235. "remaining_weight": 900.0,
  236. "used_weight": 100.0,
  237. "registered": _iso(120),
  238. "first_used": _iso(21),
  239. "last_used": _iso(2),
  240. "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
  241. }
  242. ]
  243. )
  244. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
  245. ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
  246. assert forecast.daily_rate_g == pytest.approx(100 / 21, rel=1e-3)
  247. async def test_the_built_in_stock_alerts_end_a_spool_at_its_last_use(db_session):
  248. """Created 120 days ago, last used 100 days ago: nothing in the last 90."""
  249. from backend.app.services.print_scheduler import PrintScheduler
  250. spool = await _built_in_spool(db_session)
  251. spool.weight_used = 300.0
  252. spool.created_at = utcnow_naive() - timedelta(days=120)
  253. spool.last_used = utcnow_naive() - timedelta(days=100)
  254. await db_session.commit()
  255. ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
  256. assert forecast.daily_rate_g == 0
  257. async def test_the_spoolman_stock_alerts_end_a_spool_at_its_last_use(db_session):
  258. """Used from 150 to 60 days ago: 30 of its 90 days of use are in the window,
  259. so 30 of its 90 g. Same as the panel's test."""
  260. from backend.app.services.print_scheduler import PrintScheduler
  261. db_session.add(Settings(key="spoolman_enabled", value="true"))
  262. await db_session.commit()
  263. def _iso(days):
  264. return (utcnow_naive() - timedelta(days=days)).isoformat() + "Z"
  265. spoolman = MagicMock()
  266. spoolman.get_all_spools = AsyncMock(
  267. return_value=[
  268. {
  269. "id": 4,
  270. "remaining_weight": 910.0,
  271. "used_weight": 90.0,
  272. "registered": _iso(200),
  273. "first_used": _iso(150),
  274. "last_used": _iso(60),
  275. "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
  276. }
  277. ]
  278. )
  279. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
  280. ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
  281. assert forecast.daily_rate_g == pytest.approx(30 / 90, rel=1e-3)
  282. async def test_rows_of_an_earlier_spool_with_the_same_id_are_not_its_own(db_session):
  283. """Spoolman handed id 6 to a new spool, registered 15 days ago; rows from
  284. before that are the deleted spool's."""
  285. from backend.app.services.print_scheduler import PrintScheduler
  286. from backend.app.services.stock_forecast import UsageRecord, history_rate
  287. db_session.add(Settings(key="spoolman_enabled", value="true"))
  288. db_session.add_all([_row(spoolman_spool_id=6, grams=30.0, days_ago=d) for d in (40, 30, 10, 5)])
  289. await db_session.commit()
  290. spoolman = MagicMock()
  291. spoolman.get_all_spools = AsyncMock(
  292. return_value=[
  293. {
  294. "id": 6,
  295. "remaining_weight": 940.0,
  296. "used_weight": 60.0,
  297. "registered": (utcnow_naive() - timedelta(days=15)).isoformat() + "Z",
  298. "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
  299. }
  300. ]
  301. )
  302. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
  303. ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
  304. own = history_rate([UsageRecord(utcnow_naive() - timedelta(days=d), 30.0) for d in (10, 5)], utcnow_naive())
  305. assert forecast.daily_rate_g == pytest.approx(own[0], rel=1e-3)