test_stock_alert_producer_2955.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065
  1. """The two Inventory stock alerts have a producer now (issue #2955).
  2. ``on_stock_reorder_alert`` and ``on_stock_break_alert`` had a column, a schema
  3. field, a route, a template and a UI toggle -- and, until #2945, could not even be
  4. saved -- but nothing that decides a SKU has reached its reorder point: the Forecast
  5. panel does that in the browser, so with no page open nothing could ever alert.
  6. ``PrintScheduler._check_stock_forecast`` runs the panel's arithmetic
  7. (``stock_forecast``) from the scheduler loop.
  8. The arithmetic itself is pinned against the panel in ``test_stock_forecast_2955``;
  9. these tests are about *when* it alerts and what it sends.
  10. The scenarios use the delta rate (grams consumed over the age of the spool)
  11. because it needs no usage history: a 1000 g spool created 100 days ago with
  12. ``used`` grams consumed runs at ``used / 100`` g/day. With a 6 day lead time and
  13. the default 14 day margin:
  14. used 920 -> 80 g left, 9.2 g/day, 8 days of stock, reorder point ~90 g : reorder
  15. used 960 -> 40 g left, 9.6 g/day, 4 days of stock : break
  16. used 500 -> 500 g left, 5 g/day, 100 days of stock : nothing
  17. """
  18. import json
  19. import logging
  20. import time
  21. from datetime import datetime, timedelta
  22. from unittest.mock import AsyncMock, MagicMock, patch
  23. import pytest
  24. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  25. from backend.app.models.notification import NotificationProvider
  26. from backend.app.models.settings import Settings
  27. from backend.app.models.spool import Spool
  28. from backend.app.services.print_scheduler import _STOCK_ALERTS_SETTING_KEY, PrintScheduler
  29. from backend.app.services.spoolman import SpoolmanUnavailableError
  30. from backend.app.utils.local_time import utcnow_naive
  31. REORDER_USED = 920.0
  32. BREAK_USED = 960.0
  33. QUIET_USED = 500.0
  34. async def _spool(
  35. db,
  36. *,
  37. used: float,
  38. color: str | None = "Black",
  39. subtype: str | None = "Basic",
  40. brand: str | None = "Bambu Lab",
  41. age_days: int = 100,
  42. archived: bool = False,
  43. archived_days_ago: float | None = None,
  44. baseline: float = 0.0,
  45. ) -> Spool:
  46. spool = Spool(
  47. material="PLA",
  48. subtype=subtype,
  49. brand=brand,
  50. color_name=color,
  51. label_weight=1000,
  52. core_weight=250,
  53. weight_used=used,
  54. weight_used_baseline=baseline,
  55. created_at=utcnow_naive() - timedelta(days=age_days),
  56. archived_at=(
  57. utcnow_naive() - timedelta(days=archived_days_ago)
  58. if archived_days_ago is not None
  59. else utcnow_naive()
  60. if archived
  61. else None
  62. ),
  63. )
  64. spool.k_profiles = []
  65. spool.assignments = []
  66. db.add(spool)
  67. await db.flush()
  68. return spool
  69. async def _sku(db, *, lead: int = 6, color: str | None = "Black", snoozed: bool = False, margin: int = 14):
  70. row = FilamentSkuSettings(
  71. material="PLA",
  72. subtype="Basic",
  73. brand="Bambu Lab",
  74. color_name=color,
  75. lead_time_days=lead,
  76. safety_margin_value=margin,
  77. safety_margin_unit="days",
  78. alerts_snoozed=snoozed,
  79. )
  80. db.add(row)
  81. await db.flush()
  82. return row
  83. @pytest.fixture
  84. def scheduler():
  85. return PrintScheduler()
  86. async def _pass(scheduler, db):
  87. """Run one check, stepping over the hourly gate (which has its own test)."""
  88. scheduler._stock_forecast_next_check = 0.0
  89. await scheduler._check_stock_forecast(db)
  90. @pytest.fixture
  91. def notify():
  92. """The service, replaced, with a provider that wants both events.
  93. Every test about the producer assumes someone wants the alerts; the guard in
  94. front of it has its own tests further down, against the real provider query.
  95. """
  96. with patch("backend.app.services.print_scheduler.notification_service") as ns:
  97. ns.on_stock_reorder_alert = AsyncMock()
  98. ns.on_stock_break_alert = AsyncMock()
  99. ns._get_providers_for_event = AsyncMock(return_value=[MagicMock()])
  100. yield ns
  101. # -- a restart does not start it over ----------------------------------------
  102. async def _stored(db) -> list | None:
  103. """What the settings row holds, parsed. None when there is no row."""
  104. from sqlalchemy import select
  105. row = (await db.execute(select(Settings).where(Settings.key == _STOCK_ALERTS_SETTING_KEY))).scalar_one_or_none()
  106. return json.loads(row.value) if row else None
  107. @pytest.mark.asyncio
  108. async def test_a_fresh_scheduler_does_not_re_send_what_is_already_stored(db_session, scheduler, notify):
  109. """The first check runs as soon as Bambuddy starts. Without the stored set, every update
  110. re-sent one message per SKU that was still low."""
  111. await _sku(db_session)
  112. await _spool(db_session, used=REORDER_USED)
  113. await db_session.commit()
  114. await _pass(scheduler, db_session)
  115. assert notify.on_stock_reorder_alert.await_count == 1
  116. restarted = PrintScheduler()
  117. await _pass(restarted, db_session)
  118. assert notify.on_stock_reorder_alert.await_count == 1
  119. notify.on_stock_break_alert.assert_not_awaited()
  120. @pytest.mark.asyncio
  121. async def test_a_stored_reorder_still_lets_the_worsening_to_a_break_through_after_a_restart(
  122. db_session, scheduler, notify
  123. ):
  124. await _sku(db_session)
  125. spool = await _spool(db_session, used=REORDER_USED)
  126. await db_session.commit()
  127. await _pass(scheduler, db_session)
  128. spool.weight_used = BREAK_USED
  129. await db_session.commit()
  130. restarted = PrintScheduler()
  131. await _pass(restarted, db_session)
  132. notify.on_stock_break_alert.assert_awaited_once()
  133. @pytest.mark.asyncio
  134. async def test_a_sku_that_cleared_while_it_was_down_alerts_again_after_a_restart(db_session, scheduler, notify):
  135. await _sku(db_session)
  136. spool = await _spool(db_session, used=REORDER_USED)
  137. await db_session.commit()
  138. await _pass(scheduler, db_session)
  139. spool.weight_used = QUIET_USED
  140. await db_session.commit()
  141. await _pass(PrintScheduler(), db_session)
  142. spool.weight_used = REORDER_USED
  143. await db_session.commit()
  144. await _pass(PrintScheduler(), db_session)
  145. assert notify.on_stock_reorder_alert.await_count == 2
  146. @pytest.mark.asyncio
  147. async def test_the_stored_set_drops_a_sku_that_no_longer_exists(db_session, scheduler, notify):
  148. await _sku(db_session)
  149. spool = await _spool(db_session, used=REORDER_USED)
  150. await db_session.commit()
  151. await _pass(scheduler, db_session)
  152. assert len(await _stored(db_session)) == 1
  153. spool.archived_at = utcnow_naive()
  154. await db_session.commit()
  155. await _pass(PrintScheduler(), db_session)
  156. assert await _stored(db_session) == []
  157. @pytest.mark.asyncio
  158. async def test_the_stored_set_is_emptied_when_nobody_wants_either_event(db_session, scheduler, real_providers):
  159. await _sku(db_session)
  160. await _spool(db_session, used=REORDER_USED)
  161. provider_row = NotificationProvider(
  162. name="p", provider_type="ntfy", config="{}", enabled=True, on_stock_reorder_alert=True
  163. )
  164. db_session.add(provider_row)
  165. await db_session.commit()
  166. await _pass(scheduler, db_session)
  167. assert len(await _stored(db_session)) == 1
  168. provider_row.on_stock_reorder_alert = False
  169. await db_session.commit()
  170. await _pass(PrintScheduler(), db_session)
  171. assert await _stored(db_session) == []
  172. @pytest.mark.asyncio
  173. async def test_an_unchanged_set_is_not_written_again(db_session, scheduler, notify):
  174. await _sku(db_session)
  175. await _spool(db_session, used=REORDER_USED)
  176. await db_session.commit()
  177. await _pass(scheduler, db_session)
  178. with patch("backend.app.core.db_dialect.upsert_setting", AsyncMock()) as upsert:
  179. await _pass(scheduler, db_session)
  180. await _pass(PrintScheduler(), db_session)
  181. upsert.assert_not_awaited()
  182. @pytest.mark.asyncio
  183. async def test_an_unreadable_stored_set_is_treated_as_empty(db_session, scheduler, notify):
  184. db_session.add(Settings(key=_STOCK_ALERTS_SETTING_KEY, value="not json {"))
  185. await _sku(db_session)
  186. await _spool(db_session, used=REORDER_USED)
  187. await db_session.commit()
  188. await _pass(scheduler, db_session)
  189. notify.on_stock_reorder_alert.assert_awaited_once()
  190. assert len(await _stored(db_session)) == 1
  191. # -- when it alerts ----------------------------------------------------------
  192. @pytest.mark.asyncio
  193. async def test_a_sku_at_its_reorder_point_alerts_once_with_its_numbers(db_session, scheduler, notify):
  194. await _sku(db_session)
  195. await _spool(db_session, used=REORDER_USED)
  196. await db_session.commit()
  197. await _pass(scheduler, db_session)
  198. notify.on_stock_break_alert.assert_not_awaited()
  199. notify.on_stock_reorder_alert.assert_awaited_once()
  200. material, brand, stock_g, rate, days_left, _db = notify.on_stock_reorder_alert.await_args.args
  201. assert (material, brand) == ("PLA", "Bambu Lab")
  202. assert stock_g == pytest.approx(80.0)
  203. assert rate == pytest.approx(9.2, rel=1e-3)
  204. assert days_left == 8
  205. kwargs = notify.on_stock_reorder_alert.await_args.kwargs
  206. assert kwargs == {"subtype": "Basic", "color": "Black", "skip_break_subscribers": False}
  207. @pytest.mark.asyncio
  208. async def test_a_sku_that_runs_out_before_the_lead_time_alerts_as_a_break(db_session, scheduler, notify):
  209. await _sku(db_session)
  210. await _spool(db_session, used=BREAK_USED)
  211. await db_session.commit()
  212. await _pass(scheduler, db_session)
  213. notify.on_stock_break_alert.assert_awaited_once()
  214. args = notify.on_stock_break_alert.await_args.args
  215. assert args[2] == pytest.approx(40.0)
  216. assert args[4] == 4 # days left
  217. assert args[5] == 6 # lead time
  218. assert notify.on_stock_break_alert.await_args.kwargs == {"subtype": "Basic", "color": "Black"}
  219. # A breaking SKU has also reached its reorder point: the reorder event goes too, minus the
  220. # providers that have the break alert on (the service applies that filter; see below).
  221. notify.on_stock_reorder_alert.assert_awaited_once()
  222. assert notify.on_stock_reorder_alert.await_args.kwargs["skip_break_subscribers"] is True
  223. @pytest.mark.asyncio
  224. async def test_a_sku_with_plenty_of_stock_stays_quiet(db_session, scheduler, notify):
  225. await _sku(db_session)
  226. await _spool(db_session, used=QUIET_USED)
  227. await db_session.commit()
  228. await _pass(scheduler, db_session)
  229. notify.on_stock_reorder_alert.assert_not_awaited()
  230. notify.on_stock_break_alert.assert_not_awaited()
  231. @pytest.mark.asyncio
  232. async def test_a_sku_that_has_not_been_used_has_no_rate_and_stays_quiet(db_session, scheduler, notify):
  233. await _sku(db_session)
  234. await _spool(db_session, used=0.0)
  235. await db_session.commit()
  236. await _pass(scheduler, db_session)
  237. notify.on_stock_reorder_alert.assert_not_awaited()
  238. notify.on_stock_break_alert.assert_not_awaited()
  239. @pytest.mark.asyncio
  240. async def test_the_global_lead_time_counts(db_session, scheduler, notify):
  241. """No SKU row at all: the lead time is the global setting, and 40 g at 9.6 g/day is 4 days."""
  242. db_session.add(Settings(key="forecast_global_lead_time_days", value="30"))
  243. await _spool(db_session, used=BREAK_USED)
  244. await db_session.commit()
  245. await _pass(scheduler, db_session)
  246. notify.on_stock_break_alert.assert_awaited_once()
  247. assert notify.on_stock_break_alert.await_args.args[5] == 30
  248. @pytest.mark.asyncio
  249. async def test_usage_history_beats_the_delta_rate_when_there_is_enough_of_it(db_session, scheduler, notify):
  250. """300 g used over 100 days is 3 g/day and 700 g left is months of stock. But the
  251. last three days took 300 g each: the history rate says two days, so it is a break.
  252. Also the only test that reads ``spool_usage_history`` through the scheduler.
  253. """
  254. from backend.app.models.spool_usage_history import SpoolUsageHistory
  255. await _sku(db_session)
  256. spool = await _spool(db_session, used=300.0)
  257. for days_ago in (3, 2, 1):
  258. db_session.add(
  259. SpoolUsageHistory(
  260. spool_id=spool.id, weight_used=300.0, created_at=utcnow_naive() - timedelta(days=days_ago)
  261. )
  262. )
  263. await db_session.commit()
  264. await _pass(scheduler, db_session)
  265. notify.on_stock_break_alert.assert_awaited_once()
  266. @pytest.mark.asyncio
  267. async def test_history_of_a_reset_spool_is_left_out(db_session, scheduler):
  268. """Same history, but the spool was reset (baseline set): its earlier events have no
  269. anchor, so the rate falls back to the delta rate. They still date when the
  270. spool came into use (#3407): 200 g since the reset over those 3 days."""
  271. from backend.app.models.spool_usage_history import SpoolUsageHistory
  272. from backend.app.services.stock_forecast import UsageRecord, history_rate
  273. await _sku(db_session)
  274. spool = await _spool(db_session, used=300.0, baseline=100.0)
  275. for days_ago in (3, 2, 1):
  276. db_session.add(
  277. SpoolUsageHistory(
  278. spool_id=spool.id, weight_used=300.0, created_at=utcnow_naive() - timedelta(days=days_ago)
  279. )
  280. )
  281. await db_session.commit()
  282. ((forecast, _),) = (await scheduler._stock_forecasts(db_session)).values()
  283. trend = history_rate(
  284. [UsageRecord(utcnow_naive() - timedelta(days=d), 300.0) for d in (3, 2, 1)],
  285. utcnow_naive(),
  286. )
  287. assert trend is not None and forecast.daily_rate_g != pytest.approx(trend[0])
  288. assert forecast.daily_rate_g == pytest.approx(200 / 3, rel=1e-3)
  289. # -- once per transition -----------------------------------------------------
  290. @pytest.mark.asyncio
  291. async def test_does_not_repeat_on_the_next_pass(db_session, scheduler, notify):
  292. await _sku(db_session)
  293. await _spool(db_session, used=REORDER_USED)
  294. await db_session.commit()
  295. await _pass(scheduler, db_session)
  296. await _pass(scheduler, db_session)
  297. await _pass(scheduler, db_session)
  298. notify.on_stock_reorder_alert.assert_awaited_once()
  299. @pytest.mark.asyncio
  300. async def test_re_arms_once_the_condition_clears(db_session, scheduler, notify):
  301. """Restocked (a fresh roll brings the SKU back above the line), then low again."""
  302. await _sku(db_session)
  303. spool = await _spool(db_session, used=REORDER_USED)
  304. await db_session.commit()
  305. await _pass(scheduler, db_session)
  306. assert notify.on_stock_reorder_alert.await_count == 1
  307. spool.weight_used = QUIET_USED
  308. await db_session.commit()
  309. await _pass(scheduler, db_session)
  310. assert notify.on_stock_reorder_alert.await_count == 1
  311. spool.weight_used = REORDER_USED
  312. await db_session.commit()
  313. await _pass(scheduler, db_session)
  314. assert notify.on_stock_reorder_alert.await_count == 2
  315. @pytest.mark.asyncio
  316. async def test_worsening_from_reorder_to_break_alerts_again_as_a_break(db_session, scheduler, notify):
  317. await _sku(db_session)
  318. spool = await _spool(db_session, used=REORDER_USED)
  319. await db_session.commit()
  320. await _pass(scheduler, db_session)
  321. spool.weight_used = BREAK_USED
  322. await db_session.commit()
  323. await _pass(scheduler, db_session)
  324. await _pass(scheduler, db_session)
  325. notify.on_stock_reorder_alert.assert_awaited_once()
  326. notify.on_stock_break_alert.assert_awaited_once()
  327. @pytest.mark.asyncio
  328. async def test_easing_from_break_to_reorder_is_not_announced_again(db_session, scheduler, notify):
  329. """Ordered a few rolls' worth, so it is no longer a break but still under the reorder point.
  330. "Reached the reorder point" straight after "order immediately" is the opposite news."""
  331. await _sku(db_session)
  332. spool = await _spool(db_session, used=BREAK_USED)
  333. await db_session.commit()
  334. await _pass(scheduler, db_session)
  335. spool.weight_used = REORDER_USED
  336. await db_session.commit()
  337. await _pass(scheduler, db_session)
  338. assert notify.on_stock_break_alert.await_count == 1
  339. assert notify.on_stock_reorder_alert.await_count == 1 # the break's own, from before it eased
  340. # Worse again is news again.
  341. spool.weight_used = BREAK_USED
  342. await db_session.commit()
  343. await _pass(scheduler, db_session)
  344. assert notify.on_stock_break_alert.await_count == 2
  345. assert notify.on_stock_reorder_alert.await_count == 1
  346. @pytest.mark.asyncio
  347. async def test_two_colours_of_one_product_alert_separately_and_say_which(db_session, scheduler, notify):
  348. """The SKU is material/subtype/brand/colour, so the messages must differ by colour."""
  349. await _sku(db_session, color="Black")
  350. await _sku(db_session, color="White")
  351. await _spool(db_session, used=REORDER_USED, color="Black")
  352. await _spool(db_session, used=REORDER_USED, color="White")
  353. await db_session.commit()
  354. await _pass(scheduler, db_session)
  355. assert notify.on_stock_reorder_alert.await_count == 2
  356. colours = {call.kwargs["color"] for call in notify.on_stock_reorder_alert.await_args_list}
  357. assert colours == {"Black", "White"}
  358. @pytest.mark.asyncio
  359. async def test_a_sku_that_disappears_and_comes_back_low_alerts_again(db_session, scheduler, notify):
  360. """The roll runs out and is archived, so the SKU has no spools; a new roll is later low.
  361. The old alert must not silence the new roll: nothing has cleared the SKU's
  362. entry, because it was not in the forecast to be cleared.
  363. """
  364. await _sku(db_session)
  365. spool = await _spool(db_session, used=REORDER_USED)
  366. await db_session.commit()
  367. await _pass(scheduler, db_session)
  368. assert notify.on_stock_reorder_alert.await_count == 1
  369. spool.archived_at = utcnow_naive()
  370. await db_session.commit()
  371. await _pass(scheduler, db_session)
  372. await _spool(db_session, used=REORDER_USED)
  373. await db_session.commit()
  374. await _pass(scheduler, db_session)
  375. assert notify.on_stock_reorder_alert.await_count == 2
  376. @pytest.mark.asyncio
  377. async def test_spools_of_one_sku_are_added_together(db_session, scheduler, notify):
  378. """Two half-full spools are one SKU with 1000 g, not two SKUs with 500 g each."""
  379. await _sku(db_session)
  380. await _spool(db_session, used=REORDER_USED) # 80 g left, on its own a reorder
  381. await _spool(db_session, used=QUIET_USED) # 500 g left; together 580 g at 14.2 g/day, 40 days
  382. await db_session.commit()
  383. await _pass(scheduler, db_session)
  384. notify.on_stock_reorder_alert.assert_not_awaited()
  385. notify.on_stock_break_alert.assert_not_awaited()
  386. # -- snooze and archive ------------------------------------------------------
  387. @pytest.mark.asyncio
  388. async def test_a_snoozed_sku_does_not_alert_and_alerts_when_unsnoozed(db_session, scheduler, notify):
  389. row = await _sku(db_session, snoozed=True)
  390. await _spool(db_session, used=REORDER_USED)
  391. await db_session.commit()
  392. await _pass(scheduler, db_session)
  393. notify.on_stock_reorder_alert.assert_not_awaited()
  394. row.alerts_snoozed = False
  395. await db_session.commit()
  396. await _pass(scheduler, db_session)
  397. notify.on_stock_reorder_alert.assert_awaited_once()
  398. @pytest.mark.asyncio
  399. async def test_a_colour_with_no_row_of_its_own_uses_the_colourless_row(db_session, scheduler, notify):
  400. """Settings saved before colour became part of the key live on the colourless row."""
  401. await _sku(db_session, color=None, snoozed=True)
  402. await _spool(db_session, used=REORDER_USED)
  403. await db_session.commit()
  404. await _pass(scheduler, db_session)
  405. notify.on_stock_reorder_alert.assert_not_awaited()
  406. @pytest.mark.asyncio
  407. async def test_an_archived_spool_is_not_stock(db_session, scheduler, notify):
  408. """It would supply 900 g of stock the SKU does not have."""
  409. await _sku(db_session)
  410. await _spool(db_session, used=REORDER_USED)
  411. await _spool(db_session, used=0.0, archived=True)
  412. await db_session.commit()
  413. await _pass(scheduler, db_session)
  414. notify.on_stock_reorder_alert.assert_awaited_once()
  415. assert notify.on_stock_reorder_alert.await_args.args[2] == pytest.approx(80.0)
  416. @pytest.mark.asyncio
  417. async def test_a_used_up_spool_still_counts_towards_the_rate(db_session, scheduler, notify):
  418. """#3399: 300 g left. On its own the spool ran 7 g/day, but a 1000 g spool of the
  419. same SKU was used up and archived 10 days ago: 17 g/day, under the reorder point."""
  420. await _sku(db_session)
  421. await _spool(db_session, used=700.0)
  422. await _spool(db_session, used=1000.0, archived_days_ago=10)
  423. await db_session.commit()
  424. await _pass(scheduler, db_session)
  425. notify.on_stock_reorder_alert.assert_awaited_once()
  426. # Its stock is still the 300 g of the spool that is left.
  427. assert notify.on_stock_reorder_alert.await_args.args[2] == pytest.approx(300.0)
  428. @pytest.mark.asyncio
  429. async def test_a_spool_archived_over_90_days_ago_does_not_count(db_session, scheduler, notify):
  430. await _sku(db_session)
  431. await _spool(db_session, used=700.0)
  432. await _spool(db_session, used=1000.0, archived_days_ago=91)
  433. await db_session.commit()
  434. await _pass(scheduler, db_session)
  435. notify.on_stock_reorder_alert.assert_not_awaited()
  436. # -- failure and cost --------------------------------------------------------
  437. @pytest.mark.asyncio
  438. async def test_a_failing_provider_does_not_break_the_pass(db_session, scheduler, notify):
  439. await _sku(db_session)
  440. await _sku(db_session, color="White")
  441. await _spool(db_session, used=REORDER_USED, color="Black")
  442. await _spool(db_session, used=REORDER_USED, color="White")
  443. await db_session.commit()
  444. notify.on_stock_reorder_alert.side_effect = [RuntimeError("provider down"), None]
  445. await _pass(scheduler, db_session)
  446. assert notify.on_stock_reorder_alert.await_count == 2
  447. # The failed alert is lost, not retried every hour: that repetition is what the
  448. # debounce is for, and a provider that is down is the case where it would never stop.
  449. notify.on_stock_reorder_alert.side_effect = None
  450. await _pass(scheduler, db_session)
  451. assert notify.on_stock_reorder_alert.await_count == 2
  452. @pytest.mark.asyncio
  453. async def test_a_second_pass_inside_the_interval_does_no_work(db_session, scheduler, notify):
  454. """The gate. run() re-enters the checks every 3 s while an upload is in flight."""
  455. await _spool(db_session, used=REORDER_USED)
  456. await db_session.commit()
  457. with patch.object(scheduler, "_stock_forecasts", new_callable=AsyncMock) as work:
  458. work.return_value = {}
  459. await scheduler._check_stock_forecast(db_session)
  460. wait = scheduler._stock_forecast_next_check - time.monotonic()
  461. await scheduler._check_stock_forecast(db_session)
  462. await scheduler._check_stock_forecast(db_session)
  463. assert work.await_count == 1
  464. # Hourly: the forecast is in whole days, so a finer check repeats the same answer.
  465. assert 3500 < wait <= 3600
  466. # -- the provider guard ------------------------------------------------------
  467. async def _provider(db, *, enabled: bool = True, reorder: bool = False, brk: bool = False) -> None:
  468. db.add(
  469. NotificationProvider(
  470. name=f"p-{enabled}-{reorder}-{brk}",
  471. provider_type="ntfy",
  472. config="{}",
  473. enabled=enabled,
  474. on_stock_reorder_alert=reorder,
  475. on_stock_break_alert=brk,
  476. )
  477. )
  478. await db.flush()
  479. @pytest.fixture
  480. def real_providers():
  481. """The real service, with only the two sends replaced, so the guard runs its real query."""
  482. from backend.app.services.print_scheduler import notification_service
  483. with (
  484. patch.object(notification_service, "on_stock_reorder_alert", new_callable=AsyncMock) as reorder,
  485. patch.object(notification_service, "on_stock_break_alert", new_callable=AsyncMock) as brk,
  486. ):
  487. yield reorder, brk
  488. @pytest.mark.asyncio
  489. async def test_no_work_when_no_provider_wants_either_event(db_session, scheduler, real_providers):
  490. """Both toggles default to off, so on most installs nobody wants this.
  491. Unguarded, every install read its whole spool collection every hour (in
  492. Spoolman mode, over HTTP) for alerts that were switched off. The two
  493. providers are the two ways of not wanting them: enabled with both events off,
  494. and the events on but the provider disabled.
  495. """
  496. await _provider(db_session, enabled=True)
  497. await _provider(db_session, enabled=False, reorder=True, brk=True)
  498. await _spool(db_session, used=REORDER_USED)
  499. await db_session.commit()
  500. with patch.object(scheduler, "_stock_forecasts", new_callable=AsyncMock) as work:
  501. await _pass(scheduler, db_session)
  502. work.assert_not_awaited()
  503. @pytest.mark.asyncio
  504. async def test_an_event_nobody_wants_is_not_recorded_as_sent(db_session, scheduler, real_providers):
  505. """Only the break event is on, and the SKU is at its reorder point. Switching
  506. the reorder event on later must report it -- it was never sent."""
  507. reorder, brk = real_providers
  508. await _sku(db_session)
  509. await _spool(db_session, used=REORDER_USED)
  510. provider_row = NotificationProvider(
  511. name="p", provider_type="ntfy", config="{}", enabled=True, on_stock_break_alert=True
  512. )
  513. db_session.add(provider_row)
  514. await db_session.commit()
  515. await _pass(scheduler, db_session)
  516. reorder.assert_not_awaited()
  517. brk.assert_not_awaited()
  518. provider_row.on_stock_reorder_alert = True
  519. await db_session.commit()
  520. await _pass(scheduler, db_session)
  521. reorder.assert_awaited_once()
  522. @pytest.mark.asyncio
  523. async def test_a_reorder_only_provider_still_hears_about_a_break(db_session, scheduler, real_providers):
  524. """A SKU that is breaking has certainly reached its reorder point, which is what the
  525. Reorder Alert toggle promises. Without this, the worst state is the one a provider
  526. with only that toggle on is never told about."""
  527. reorder, brk = real_providers
  528. await _sku(db_session)
  529. await _spool(db_session, used=BREAK_USED)
  530. await _provider(db_session, reorder=True)
  531. await db_session.commit()
  532. await _pass(scheduler, db_session)
  533. reorder.assert_awaited_once()
  534. assert reorder.await_args.kwargs["skip_break_subscribers"] is True
  535. brk.assert_not_awaited() # nobody has it on
  536. @pytest.mark.asyncio
  537. async def test_switching_the_break_alert_on_later_reports_the_break(db_session, scheduler, real_providers):
  538. """The reorder-only provider was told when the SKU broke. A provider that turns the break
  539. alert on afterwards is told too -- and the reorder-only one is not told a second time."""
  540. reorder, brk = real_providers
  541. await _sku(db_session)
  542. await _spool(db_session, used=BREAK_USED)
  543. await _provider(db_session, reorder=True)
  544. await db_session.commit()
  545. await _pass(scheduler, db_session)
  546. assert reorder.await_count == 1
  547. await _provider(db_session, brk=True)
  548. await db_session.commit()
  549. await _pass(scheduler, db_session)
  550. brk.assert_awaited_once()
  551. assert reorder.await_count == 1
  552. @pytest.mark.asyncio
  553. async def test_switching_one_event_off_and_on_again_reports_it_while_the_other_stays_on(
  554. db_session, scheduler, real_providers
  555. ):
  556. """A wants reorders, B wants breaks, and the SKU is breaking: both were told. B switches
  557. the break alert off (A keeps the check running) and on again: B is told again."""
  558. reorder, brk = real_providers
  559. await _sku(db_session)
  560. await _spool(db_session, used=BREAK_USED)
  561. await _provider(db_session, reorder=True)
  562. b_provider = NotificationProvider(
  563. name="b", provider_type="ntfy", config="{}", enabled=True, on_stock_break_alert=True
  564. )
  565. db_session.add(b_provider)
  566. await db_session.commit()
  567. await _pass(scheduler, db_session)
  568. assert (reorder.await_count, brk.await_count) == (1, 1)
  569. b_provider.on_stock_break_alert = False
  570. await db_session.commit()
  571. await _pass(scheduler, db_session)
  572. b_provider.on_stock_break_alert = True
  573. await db_session.commit()
  574. await _pass(scheduler, db_session)
  575. assert brk.await_count == 2
  576. assert reorder.await_count == 1
  577. @pytest.mark.asyncio
  578. async def test_a_break_alert_only_provider_is_not_told_about_a_reorder(db_session, scheduler, real_providers):
  579. reorder, brk = real_providers
  580. await _sku(db_session)
  581. await _spool(db_session, used=REORDER_USED)
  582. await _provider(db_session, brk=True)
  583. await db_session.commit()
  584. await _pass(scheduler, db_session)
  585. reorder.assert_not_awaited()
  586. brk.assert_not_awaited()
  587. @pytest.mark.asyncio
  588. async def test_a_provider_with_both_on_gets_the_break_and_no_second_message(db_session, scheduler):
  589. """Through the real service: A has both toggles on, B only reorder. A break SKU goes to A as
  590. a break and to B as a reorder -- and A does not also get the reorder."""
  591. from backend.app.services.notification_service import NotificationService
  592. from backend.app.services.print_scheduler import notification_service
  593. await _sku(db_session)
  594. await _spool(db_session, used=BREAK_USED)
  595. both = NotificationProvider(
  596. name="both",
  597. provider_type="ntfy",
  598. config="{}",
  599. enabled=True,
  600. on_stock_reorder_alert=True,
  601. on_stock_break_alert=True,
  602. )
  603. only_reorder = NotificationProvider(
  604. name="only-reorder", provider_type="ntfy", config="{}", enabled=True, on_stock_reorder_alert=True
  605. )
  606. db_session.add_all([both, only_reorder])
  607. await db_session.commit()
  608. sent: list[tuple[str, list[str]]] = []
  609. async def record(providers, title, message, db, event_type, **kwargs):
  610. sent.append((event_type, sorted(p.name for p in providers)))
  611. with (
  612. patch.object(notification_service, "_send_to_providers", side_effect=record),
  613. patch.object(notification_service, "_build_message_from_template", AsyncMock(return_value=("t", "b"))),
  614. ):
  615. await _pass(scheduler, db_session)
  616. assert isinstance(notification_service, NotificationService)
  617. assert sorted(sent) == [("stock_break_alert", ["both"]), ("stock_reorder_alert", ["only-reorder"])]
  618. @pytest.mark.asyncio
  619. async def test_switching_every_event_off_and_on_again_reports_what_is_low_now(db_session, scheduler, real_providers):
  620. reorder, _brk = real_providers
  621. await _sku(db_session)
  622. await _spool(db_session, used=REORDER_USED)
  623. provider_row = NotificationProvider(
  624. name="p", provider_type="ntfy", config="{}", enabled=True, on_stock_reorder_alert=True
  625. )
  626. db_session.add(provider_row)
  627. await db_session.commit()
  628. await _pass(scheduler, db_session)
  629. assert reorder.await_count == 1
  630. provider_row.on_stock_reorder_alert = False
  631. await db_session.commit()
  632. await _pass(scheduler, db_session)
  633. provider_row.on_stock_reorder_alert = True
  634. await db_session.commit()
  635. await _pass(scheduler, db_session)
  636. assert reorder.await_count == 2
  637. # -- Spoolman mode -----------------------------------------------------------
  638. def _spoolman_spool(spool_id: int, used: float, *, color: str = "Black", age_days: int = 100) -> dict:
  639. """A raw Spoolman spool, in the shape _map_spoolman_spool reads."""
  640. registered = (utcnow_naive() - timedelta(days=age_days)).isoformat() + "Z"
  641. return {
  642. "id": spool_id,
  643. "remaining_weight": 1000 - used,
  644. "used_weight": used,
  645. "registered": registered,
  646. "filament": {
  647. "id": 1,
  648. "name": "PLA Basic",
  649. "material": "PLA",
  650. "weight": 1000,
  651. "color_name": color,
  652. "vendor": {"name": "Bambu Lab"},
  653. },
  654. }
  655. def _spoolman_client(spools, *, unavailable: bool = False):
  656. client = MagicMock()
  657. if unavailable:
  658. client.get_all_spools = AsyncMock(side_effect=SpoolmanUnavailableError("Cannot reach Spoolman"))
  659. else:
  660. client.get_all_spools = AsyncMock(return_value=spools)
  661. return client
  662. @pytest.mark.asyncio
  663. async def test_spoolman_mode_alerts_from_one_collection_read(db_session, scheduler, notify):
  664. db_session.add(Settings(key="spoolman_enabled", value="true"))
  665. await db_session.commit()
  666. client = _spoolman_client([_spoolman_spool(7, used=REORDER_USED), _spoolman_spool(8, used=0.0, color="White")])
  667. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
  668. await _pass(scheduler, db_session)
  669. # Black has 80 g at 9.2 g/day and no SKU row: lead time 0, so a reorder needs the
  670. # 14 day margin -- 80 g is under the ~128 g that gives.
  671. notify.on_stock_reorder_alert.assert_awaited_once()
  672. assert notify.on_stock_reorder_alert.await_args.kwargs["color"] == "Black"
  673. client.get_all_spools.assert_awaited_once()
  674. @pytest.mark.asyncio
  675. async def test_spoolman_mode_counts_a_used_up_spool_towards_the_rate(db_session, scheduler):
  676. """#3399 in Spoolman mode: archived spools are read too, dated by their last use.
  677. The forecast itself is checked: an alert fires the same for any rate above the
  678. reorder point, so it could not tell which archived spools counted.
  679. """
  680. db_session.add(Settings(key="spoolman_enabled", value="true"))
  681. await db_session.commit()
  682. recent = _spoolman_spool(9, used=900.0) # 100 g left on it: not stock either way
  683. recent["archived"] = True
  684. recent["last_used"] = (utcnow_naive() - timedelta(days=10)).isoformat() + "Z"
  685. stale = _spoolman_spool(10, used=900.0)
  686. stale["archived"] = True
  687. stale["last_used"] = (utcnow_naive() - timedelta(days=91)).isoformat() + "Z"
  688. never_used = _spoolman_spool(11, used=900.0)
  689. never_used["archived"] = True # dated by its registration 100 days ago: too old
  690. undated = _spoolman_spool(12, used=900.0)
  691. undated["archived"] = True
  692. del undated["registered"] # no date at all: not stock, and it doesn't count
  693. client = _spoolman_client([_spoolman_spool(7, used=760.0), recent, stale, never_used, undated])
  694. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
  695. forecasts = await scheduler._stock_forecasts(db_session)
  696. client.get_all_spools.assert_awaited_once_with(allow_archived=True)
  697. ((forecast, named_by),) = forecasts.values()
  698. # What falls inside the last 90 days (#3407): 684 of the stock spool's 760 g
  699. # (used over 100 days) and 800 of the used-up one's 900 (used over 90, up to
  700. # 10 days ago). The stale ones add nothing.
  701. assert forecast.daily_rate_g == pytest.approx((684 + 800) / 90)
  702. assert forecast.remaining_g == pytest.approx(240.0)
  703. assert named_by.id == 7
  704. @pytest.mark.asyncio
  705. async def test_an_unreachable_spoolman_stays_quiet_and_does_not_forget(db_session, scheduler, notify, caplog):
  706. """An outage is an ordinary state for a third-party service. It must not raise, and it
  707. must not clear what was sent: the SKU would alert again the moment Spoolman came back."""
  708. db_session.add(Settings(key="spoolman_enabled", value="true"))
  709. await db_session.commit()
  710. spools = [_spoolman_spool(7, used=REORDER_USED)]
  711. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client(spools))):
  712. await _pass(scheduler, db_session)
  713. assert notify.on_stock_reorder_alert.await_count == 1
  714. down = _spoolman_client([], unavailable=True)
  715. with (
  716. caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"),
  717. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=down)),
  718. ):
  719. await _pass(scheduler, db_session)
  720. # An ordinary state for a third-party service, not a traceback every hour.
  721. assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
  722. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client(spools))):
  723. await _pass(scheduler, db_session)
  724. assert notify.on_stock_reorder_alert.await_count == 1
  725. @pytest.mark.asyncio
  726. async def test_a_colour_spoolman_made_up_from_the_subtype_is_not_sent_as_a_colour(db_session, scheduler, notify):
  727. """With no colour of its own, Spoolman mode shows the subtype in its place so the list has
  728. no blank colours. In a message that reads "PLA Basic Basic", so the colour is left out."""
  729. db_session.add(Settings(key="spoolman_enabled", value="true"))
  730. await db_session.commit()
  731. raw = _spoolman_spool(7, used=REORDER_USED)
  732. del raw["filament"]["color_name"]
  733. raw["filament"]["name"] = "Basic"
  734. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client([raw]))):
  735. await _pass(scheduler, db_session)
  736. notify.on_stock_reorder_alert.assert_awaited_once()
  737. assert notify.on_stock_reorder_alert.await_args.kwargs["color"] is None
  738. @pytest.mark.asyncio
  739. async def test_spoolman_mode_does_not_borrow_a_local_spools_history(db_session, scheduler, notify):
  740. """A Spoolman id that matches a local spool id (left from before Spoolman was switched on)
  741. must not pick up that spool's usage history. Here the local spool 1 has a history that would
  742. make this a break; Spoolman's spool 1 is quiet by its own delta rate."""
  743. from backend.app.models.spool_usage_history import SpoolUsageHistory
  744. local = await _spool(db_session, used=300.0)
  745. for days_ago in (3, 2, 1):
  746. db_session.add(
  747. SpoolUsageHistory(
  748. spool_id=local.id, weight_used=300.0, created_at=utcnow_naive() - timedelta(days=days_ago)
  749. )
  750. )
  751. db_session.add(Settings(key="spoolman_enabled", value="true"))
  752. await _sku(db_session)
  753. await db_session.commit()
  754. client = _spoolman_client([_spoolman_spool(local.id, used=300.0)])
  755. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
  756. await _pass(scheduler, db_session)
  757. notify.on_stock_break_alert.assert_not_awaited()
  758. notify.on_stock_reorder_alert.assert_not_awaited()
  759. # -- the service -------------------------------------------------------------
  760. @pytest.mark.asyncio
  761. async def test_the_events_reach_a_provider_with_subtype_and_colour():
  762. """Both events pass subtype and colour through to the template variables a provider is sent."""
  763. from backend.app.services.notification_service import NotificationService
  764. service = NotificationService()
  765. provider = MagicMock()
  766. provider.id = 1
  767. with (
  768. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  769. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  770. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  771. ):
  772. mock_get.return_value = [provider]
  773. mock_build.return_value = ("t", "b")
  774. await service.on_stock_reorder_alert(
  775. "PLA", "Bambu Lab", 80.0, 9.2, 8, AsyncMock(), subtype="Matte", color="Charcoal"
  776. )
  777. await service.on_stock_break_alert(
  778. "PLA", "Bambu Lab", 40.0, 9.6, 4, 6, AsyncMock(), subtype="Matte", color="Charcoal"
  779. )
  780. assert [c.args[1] for c in mock_get.await_args_list] == ["on_stock_reorder_alert", "on_stock_break_alert"]
  781. assert mock_send.await_count == 2
  782. for call in mock_send.await_args_list:
  783. variables = call.kwargs["variables"]
  784. assert variables["subtype"] == "Matte"
  785. assert variables["color"] == "Charcoal"
  786. assert variables["material"] == "PLA"
  787. @pytest.mark.asyncio
  788. async def test_subtype_and_colour_are_optional_so_the_old_call_shape_still_works():
  789. """Neither method had a caller, but the new arguments are keyword-only with defaults regardless."""
  790. from backend.app.services.notification_service import NotificationService
  791. service = NotificationService()
  792. with (
  793. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock, return_value=[MagicMock()]),
  794. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  795. patch.object(service, "_build_message_from_template", new_callable=AsyncMock, return_value=("t", "b")),
  796. ):
  797. await service.on_stock_reorder_alert("PLA", None, 80.0, 9.2, 8, AsyncMock())
  798. variables = mock_send.await_args.kwargs["variables"]
  799. assert variables["subtype"] == ""
  800. assert variables["color"] == ""
  801. @pytest.mark.asyncio
  802. async def test_a_spoolman_registered_date_with_a_trailing_z_still_gives_the_spool_an_age(db_session, scheduler, notify):
  803. """Python 3.10's fromisoformat rejects a trailing "Z" (Bambuddy ran on 3.10 when this was written), which
  804. silently dropped the spool's age and with it the delta rate. Simulated here so the test
  805. means the same on every interpreter."""
  806. class _Py310Datetime(datetime):
  807. @classmethod
  808. def fromisoformat(cls, value):
  809. if value.endswith("Z"):
  810. raise ValueError(f"Invalid isoformat string: {value!r}")
  811. return super().fromisoformat(value)
  812. db_session.add(Settings(key="spoolman_enabled", value="true"))
  813. await db_session.commit()
  814. client = _spoolman_client([_spoolman_spool(7, used=REORDER_USED)])
  815. with (
  816. patch("backend.app.services.print_scheduler.datetime", _Py310Datetime),
  817. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
  818. ):
  819. await _pass(scheduler, db_session)
  820. # 920 g in 100 days is 9.2 g/day: a reorder. With the age dropped the rate falls back to a
  821. # different number and this SKU is not at its reorder point.
  822. notify.on_stock_reorder_alert.assert_awaited_once()
  823. assert notify.on_stock_reorder_alert.await_args.args[3] == pytest.approx(9.2, abs=0.05)