test_queue_history_api.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. """Queue history, paged on the server (GET /queue/history, POST /queue/history/clear).
  2. The Queue page used to load the whole queue every five seconds and split off
  3. the history in the browser. On a print farm that was thousands of rows and
  4. stalled the server for over a minute. These tests pin that the server-side
  5. version shows the same rows, in the same order, under the same filters, and
  6. that clearing it keeps the rule the one-by-one DELETEs followed (#2960).
  7. """
  8. from __future__ import annotations
  9. from datetime import datetime, timedelta
  10. import pytest
  11. from httpx import AsyncClient
  12. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  13. URL = "/api/v1/queue/history"
  14. T0 = datetime(2026, 10, 1, 12, 0)
  15. @pytest.fixture
  16. def queue_item_factory(db_session):
  17. position = [0]
  18. async def _create(archive_id: int | None, printer_id: int | None = None, **kwargs):
  19. from backend.app.models.print_queue import PrintQueueItem
  20. position[0] += 1
  21. defaults = {
  22. "archive_id": archive_id,
  23. "printer_id": printer_id,
  24. "status": "completed",
  25. "position": position[0],
  26. "created_at": T0,
  27. }
  28. defaults.update(kwargs)
  29. item = PrintQueueItem(**defaults)
  30. db_session.add(item)
  31. await db_session.commit()
  32. await db_session.refresh(item)
  33. return item
  34. return _create
  35. async def _ids(async_client: AsyncClient, **params) -> list[int]:
  36. response = await async_client.get(URL, params=params)
  37. assert response.status_code == 200, response.text
  38. return [row["id"] for row in response.json()["items"]]
  39. class TestListing:
  40. async def test_only_history_statuses(self, async_client, printer_factory, archive_factory, queue_item_factory):
  41. printer = await printer_factory()
  42. archive = await archive_factory(printer.id)
  43. history = [
  44. await queue_item_factory(archive.id, printer.id, status=status)
  45. for status in ("completed", "failed", "skipped", "cancelled")
  46. ]
  47. await queue_item_factory(archive.id, printer.id, status="pending")
  48. await queue_item_factory(archive.id, printer.id, status="printing")
  49. response = await async_client.get(URL)
  50. assert response.status_code == 200, response.text
  51. assert sorted(row["id"] for row in response.json()["items"]) == sorted(i.id for i in history)
  52. assert response.json()["total"] == 4
  53. async def test_newest_first_by_completion_then_creation(
  54. self, async_client, printer_factory, archive_factory, queue_item_factory
  55. ):
  56. printer = await printer_factory()
  57. archive = await archive_factory(printer.id)
  58. old = await queue_item_factory(archive.id, printer.id, completed_at=T0)
  59. never_finished = await queue_item_factory(
  60. archive.id, printer.id, status="cancelled", created_at=T0 + timedelta(hours=2)
  61. )
  62. new = await queue_item_factory(archive.id, printer.id, completed_at=T0 + timedelta(hours=3))
  63. assert await _ids(async_client) == [new.id, never_finished.id, old.id]
  64. assert await _ids(async_client, reverse=True) == [old.id, never_finished.id, new.id]
  65. async def test_sort_by_name_is_a_to_z_and_ignores_case(
  66. self, async_client, printer_factory, archive_factory, queue_item_factory
  67. ):
  68. printer = await printer_factory()
  69. names = {}
  70. for name in ("bracket", "Anchor", "Clip"):
  71. archive = await archive_factory(printer.id, print_name=name)
  72. names[name] = (await queue_item_factory(archive.id, printer.id)).id
  73. assert await _ids(async_client, sort_by="name") == [names["Anchor"], names["bracket"], names["Clip"]]
  74. assert await _ids(async_client, sort_by="name", reverse=True) == [
  75. names["Clip"],
  76. names["bracket"],
  77. names["Anchor"],
  78. ]
  79. async def test_sort_by_name_skips_a_deleted_archive(
  80. self, async_client, printer_factory, archive_factory, queue_item_factory
  81. ):
  82. # A deleted archive's name isn't shown on the row, so it can't order it
  83. printer = await printer_factory()
  84. gone = await archive_factory(printer.id, print_name="Aaa", filename="zzz.3mf", deleted_at=T0)
  85. live = await archive_factory(printer.id, print_name="Mmm")
  86. gone_row = await queue_item_factory(gone.id, printer.id)
  87. live_row = await queue_item_factory(live.id, printer.id)
  88. # No archive and no library file: sorts as an empty name, first
  89. assert await _ids(async_client, sort_by="name") == [gone_row.id, live_row.id]
  90. async def test_sort_by_printer(self, async_client, printer_factory, archive_factory, queue_item_factory):
  91. zeta, alpha = await printer_factory(name="Zeta"), await printer_factory(name="alpha")
  92. archive = await archive_factory(zeta.id)
  93. on_zeta = await queue_item_factory(archive.id, zeta.id)
  94. on_alpha = await queue_item_factory(archive.id, alpha.id)
  95. assert await _ids(async_client, sort_by="printer") == [on_alpha.id, on_zeta.id]
  96. async def test_pages_with_total(self, async_client, printer_factory, archive_factory, queue_item_factory):
  97. printer = await printer_factory()
  98. archive = await archive_factory(printer.id)
  99. rows = [
  100. await queue_item_factory(archive.id, printer.id, completed_at=T0 + timedelta(minutes=i)) for i in range(5)
  101. ]
  102. newest_first = [r.id for r in reversed(rows)]
  103. response = await async_client.get(URL, params={"limit": 2, "offset": 2})
  104. assert response.status_code == 200, response.text
  105. assert [row["id"] for row in response.json()["items"]] == newest_first[2:4]
  106. assert response.json()["total"] == 5
  107. async def test_page_brings_the_other_runs_of_its_batches(
  108. self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
  109. ):
  110. from backend.app.models.print_batch import PrintBatch
  111. printer = await printer_factory()
  112. archive = await archive_factory(printer.id)
  113. batch = PrintBatch(name="Order", status="active")
  114. db_session.add(batch)
  115. await db_session.commit()
  116. older_run = await queue_item_factory(archive.id, printer.id, batch_id=batch.id, completed_at=T0)
  117. await queue_item_factory(archive.id, printer.id, completed_at=T0 + timedelta(hours=1))
  118. newer_run = await queue_item_factory(
  119. archive.id, printer.id, batch_id=batch.id, completed_at=T0 + timedelta(hours=2)
  120. )
  121. response = await async_client.get(URL, params={"limit": 1})
  122. assert response.status_code == 200, response.text
  123. # The page first, then the rest of its batch; the unbatched row stays out
  124. assert [row["id"] for row in response.json()["items"]] == [newer_run.id, older_run.id]
  125. assert response.json()["total"] == 3
  126. class TestFilters:
  127. async def test_printer_and_unassigned(self, async_client, printer_factory, archive_factory, queue_item_factory):
  128. a, b = await printer_factory(), await printer_factory()
  129. archive = await archive_factory(a.id)
  130. on_a = await queue_item_factory(archive.id, a.id)
  131. await queue_item_factory(archive.id, b.id)
  132. unassigned = await queue_item_factory(archive.id, None, status="cancelled", target_model="P2S")
  133. assert await _ids(async_client, printer_id=a.id) == [on_a.id]
  134. assert await _ids(async_client, printer_id=-1) == [unassigned.id]
  135. async def test_status(self, async_client, printer_factory, archive_factory, queue_item_factory):
  136. printer = await printer_factory()
  137. archive = await archive_factory(printer.id)
  138. failed = await queue_item_factory(archive.id, printer.id, status="failed")
  139. await queue_item_factory(archive.id, printer.id, status="completed")
  140. await queue_item_factory(archive.id, printer.id, status="pending")
  141. assert await _ids(async_client, status="failed") == [failed.id]
  142. # An active status selected on the page leaves no history to show
  143. assert await _ids(async_client, status="pending") == []
  144. async def test_location_by_target_location_or_printer(
  145. self, async_client, printer_factory, archive_factory, queue_item_factory
  146. ):
  147. hall, lab = await printer_factory(location="Hall"), await printer_factory(location="Lab")
  148. archive = await archive_factory(hall.id)
  149. in_hall = await queue_item_factory(archive.id, hall.id)
  150. await queue_item_factory(archive.id, lab.id)
  151. model_based_hall = await queue_item_factory(archive.id, None, target_model="P2S", target_location="Hall")
  152. # A target location wins over the printer's own
  153. await queue_item_factory(archive.id, hall.id, target_location="Lab")
  154. assert sorted(await _ids(async_client, location="Hall")) == sorted([in_hall.id, model_based_hall.id])
  155. async def test_location_filter_with_printer_sort(
  156. self, async_client, printer_factory, archive_factory, queue_item_factory
  157. ):
  158. # The printer sort joins printers; the location check must not get tangled in it
  159. hall, lab = await printer_factory(name="B", location="Hall"), await printer_factory(name="A", location="Lab")
  160. archive = await archive_factory(hall.id)
  161. in_hall = await queue_item_factory(archive.id, hall.id)
  162. await queue_item_factory(archive.id, lab.id)
  163. assert await _ids(async_client, location="Hall", sort_by="printer") == [in_hall.id]
  164. async def test_locations_lists_every_target_location(
  165. self, async_client, printer_factory, archive_factory, queue_item_factory
  166. ):
  167. printer = await printer_factory()
  168. archive = await archive_factory(printer.id)
  169. await queue_item_factory(archive.id, None, target_model="P2S", target_location="Hall")
  170. await queue_item_factory(archive.id, None, target_model="P2S", target_location="Annex")
  171. await queue_item_factory(archive.id, None, target_model="P2S", target_location="")
  172. await queue_item_factory(archive.id, printer.id)
  173. response = await async_client.get(URL, params={"status": "failed", "limit": 1})
  174. assert response.status_code == 200, response.text
  175. # Independent of the filters and the page
  176. assert response.json()["locations"] == ["Annex", "Hall"]
  177. class TestActiveListing:
  178. async def test_list_takes_several_statuses(
  179. self, async_client, printer_factory, archive_factory, queue_item_factory
  180. ):
  181. printer = await printer_factory()
  182. archive = await archive_factory(printer.id)
  183. pending = await queue_item_factory(archive.id, printer.id, status="pending")
  184. printing = await queue_item_factory(archive.id, printer.id, status="printing")
  185. await queue_item_factory(archive.id, printer.id, status="completed")
  186. response = await async_client.get("/api/v1/queue/", params={"status": "pending,printing"})
  187. assert response.status_code == 200, response.text
  188. assert sorted(row["id"] for row in response.json()) == sorted([pending.id, printing.id])
  189. async def test_list_single_status_unchanged(
  190. self, async_client, printer_factory, archive_factory, queue_item_factory
  191. ):
  192. printer = await printer_factory()
  193. archive = await archive_factory(printer.id)
  194. pending = await queue_item_factory(archive.id, printer.id, status="pending")
  195. await queue_item_factory(archive.id, printer.id, status="completed")
  196. response = await async_client.get("/api/v1/queue/", params={"status": "pending"})
  197. assert [row["id"] for row in response.json()] == [pending.id]
  198. class TestClear:
  199. async def test_clears_only_history_under_the_filters(
  200. self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
  201. ):
  202. from backend.app.models.print_queue import PrintQueueItem
  203. a, b = await printer_factory(), await printer_factory()
  204. archive = await archive_factory(a.id)
  205. done_on_a = await queue_item_factory(archive.id, a.id)
  206. failed_on_a = await queue_item_factory(archive.id, a.id, status="failed")
  207. done_on_b = await queue_item_factory(archive.id, b.id)
  208. pending_on_a = await queue_item_factory(archive.id, a.id, status="pending")
  209. ids = {r: r.id for r in (done_on_a, failed_on_a, done_on_b, pending_on_a)}
  210. response = await async_client.post(f"{URL}/clear", params={"printer_id": a.id})
  211. assert response.status_code == 200, response.text
  212. assert response.json() == {"cleared": 2, "kept": 0}
  213. db_session.expire_all()
  214. assert await db_session.get(PrintQueueItem, ids[done_on_a]) is None
  215. assert await db_session.get(PrintQueueItem, ids[failed_on_a]) is None
  216. assert await db_session.get(PrintQueueItem, ids[done_on_b]) is not None
  217. assert await db_session.get(PrintQueueItem, ids[pending_on_a]) is not None
  218. async def test_keeps_an_orders_last_run_as_cancelled(
  219. self, async_client, db_session, printer_factory, archive_factory
  220. ):
  221. """The same rule as deleting the run on its own (#2960)."""
  222. from backend.app.models.print_queue import PrintQueueItem
  223. printer = await printer_factory()
  224. archive = await archive_factory(printer.id)
  225. order = await async_client.post(
  226. "/api/v1/queue/batches",
  227. json={"name": "Order", "archive_id": archive.id, "plates": [{"plate_id": 1, "quantity_target": 3}]},
  228. )
  229. assert order.status_code == 200, order.text
  230. runs = []
  231. for _ in range(2):
  232. created = await async_client.post(
  233. "/api/v1/queue/",
  234. json={
  235. "printer_id": printer.id,
  236. "archive_id": archive.id,
  237. "batch_id": order.json()["id"],
  238. "plate_id": 1,
  239. },
  240. )
  241. assert created.status_code == 200, created.text
  242. runs.append(created.json()["id"])
  243. for run_id in runs:
  244. row = await db_session.get(PrintQueueItem, run_id)
  245. row.status = "failed"
  246. await db_session.commit()
  247. response = await async_client.post(f"{URL}/clear")
  248. assert response.status_code == 200, response.text
  249. assert response.json() == {"cleared": 1, "kept": 1}
  250. db_session.expire_all()
  251. # The later of the two, in queue order, is the one left to clone
  252. assert await db_session.get(PrintQueueItem, runs[0]) is None
  253. survivor = await db_session.get(PrintQueueItem, runs[1])
  254. assert survivor is not None
  255. assert survivor.status == "cancelled"
  256. # Ended now, as a cancel on its own stamps it
  257. assert survivor.completed_at is not None
  258. async def test_a_grouping_whose_last_open_job_is_cleared_is_finished(
  259. self, async_client, db_session, printer_factory, archive_factory
  260. ):
  261. """As deleting that job on its own finishes it."""
  262. from backend.app.models.print_queue import PrintQueueItem
  263. printer = await printer_factory()
  264. archive = await archive_factory(printer.id)
  265. ids = []
  266. for _ in range(2):
  267. created = await async_client.post(
  268. "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id}
  269. )
  270. ids.append(created.json()["id"])
  271. grouping = (await async_client.post("/api/v1/queue/batches", json={"name": "G", "item_ids": ids})).json()
  272. assert (await async_client.post(f"/api/v1/queue/{ids[0]}/cancel")).status_code == 200
  273. # Skipped is still open: the grouping waits for it
  274. row = await db_session.get(PrintQueueItem, ids[1])
  275. row.status = "skipped"
  276. await db_session.commit()
  277. response = await async_client.post(f"{URL}/clear", params={"status": "skipped"})
  278. assert response.json() == {"cleared": 1, "kept": 0}
  279. batch = await async_client.get(f"/api/v1/queue/batches/{grouping['id']}")
  280. assert batch.json()["status"] == "cancelled"
  281. def _auth(jwt: str) -> dict[str, str]:
  282. return {"Authorization": f"Bearer {jwt}"}
  283. async def _admin_token(async_client: AsyncClient) -> str:
  284. await async_client.post(
  285. "/api/v1/auth/setup",
  286. json={"auth_enabled": True, "admin_username": "histadmin", "admin_password": "AdminPass1!"},
  287. )
  288. login = await async_client.post("/api/v1/auth/login", json={"username": "histadmin", "password": "AdminPass1!"})
  289. assert login.status_code == 200, login.text
  290. return login.json()["access_token"]
  291. async def _user(
  292. async_client: AsyncClient, admin_jwt: str, username: str, permissions: list[str], printer_ids=None
  293. ) -> tuple[str, int]:
  294. groups = []
  295. perms = await async_client.post(
  296. "/api/v1/groups/", headers=_auth(admin_jwt), json={"name": f"perms_{username}", "permissions": permissions}
  297. )
  298. assert perms.status_code == 201, perms.text
  299. groups.append(perms.json()["id"])
  300. if printer_ids is not None:
  301. team = await async_client.post(
  302. "/api/v1/groups/",
  303. headers=_auth(admin_jwt),
  304. json={"name": f"team_{username}", "permissions": [], "restrict_printers": True, "printer_ids": printer_ids},
  305. )
  306. assert team.status_code == 201, team.text
  307. groups.append(team.json()["id"])
  308. created = await async_client.post(
  309. "/api/v1/users/",
  310. headers=_auth(admin_jwt),
  311. json={"username": username, "password": "UserPass1!", "group_ids": groups},
  312. )
  313. assert created.status_code in (200, 201), created.text
  314. login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
  315. assert login.status_code == 200, login.text
  316. return login.json()["access_token"], created.json()["id"]
  317. class TestAccess:
  318. async def test_read_own_sees_only_own_history(
  319. self, async_client, printer_factory, archive_factory, queue_item_factory
  320. ):
  321. printer = await printer_factory()
  322. archive = await archive_factory(printer.id)
  323. admin = await _admin_token(async_client)
  324. jwt, user_id = await _user(async_client, admin, "owner", ["queue:read_own"])
  325. mine = await queue_item_factory(archive.id, printer.id, created_by_id=user_id)
  326. await queue_item_factory(archive.id, printer.id)
  327. response = await async_client.get(URL, headers=_auth(jwt))
  328. assert response.status_code == 200, response.text
  329. assert [row["id"] for row in response.json()["items"]] == [mine.id]
  330. assert response.json()["total"] == 1
  331. async def test_team_member_sees_only_team_printers(
  332. self, async_client, printer_factory, archive_factory, queue_item_factory
  333. ):
  334. a, b = await printer_factory(), await printer_factory()
  335. archive = await archive_factory(a.id)
  336. admin = await _admin_token(async_client)
  337. jwt, _ = await _user(async_client, admin, "member", ["queue:read_all"], printer_ids=[a.id])
  338. on_a = await queue_item_factory(archive.id, a.id)
  339. await queue_item_factory(archive.id, b.id)
  340. response = await async_client.get(URL, headers=_auth(jwt))
  341. assert [row["id"] for row in response.json()["items"]] == [on_a.id]
  342. async def test_needs_queue_read(self, async_client):
  343. admin = await _admin_token(async_client)
  344. jwt, _ = await _user(async_client, admin, "nobody", ["printers:read"])
  345. assert (await async_client.get(URL, headers=_auth(jwt))).status_code == 403
  346. assert (await async_client.post(f"{URL}/clear", headers=_auth(jwt))).status_code == 403
  347. async def test_read_own_clears_only_own_history_even_with_delete_all(
  348. self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
  349. ):
  350. # The page only ever listed, and so deleted, the rows its user could see
  351. from backend.app.models.print_queue import PrintQueueItem
  352. printer = await printer_factory()
  353. archive = await archive_factory(printer.id)
  354. admin = await _admin_token(async_client)
  355. jwt, user_id = await _user(async_client, admin, "reader", ["queue:read_own", "queue:delete_all"])
  356. mine = await queue_item_factory(archive.id, printer.id, created_by_id=user_id)
  357. theirs = await queue_item_factory(archive.id, printer.id)
  358. mine_id, theirs_id = mine.id, theirs.id
  359. response = await async_client.post(f"{URL}/clear", headers=_auth(jwt))
  360. assert response.status_code == 200, response.text
  361. assert response.json() == {"cleared": 1, "kept": 0}
  362. db_session.expire_all()
  363. assert await db_session.get(PrintQueueItem, mine_id) is None
  364. assert await db_session.get(PrintQueueItem, theirs_id) is not None
  365. async def test_clear_needs_queue_read(self, async_client):
  366. admin = await _admin_token(async_client)
  367. jwt, _ = await _user(async_client, admin, "deleter", ["queue:delete_all"])
  368. assert (await async_client.post(f"{URL}/clear", headers=_auth(jwt))).status_code == 403
  369. async def test_delete_own_clears_only_own_history(
  370. self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
  371. ):
  372. from backend.app.models.print_queue import PrintQueueItem
  373. printer = await printer_factory()
  374. archive = await archive_factory(printer.id)
  375. admin = await _admin_token(async_client)
  376. jwt, user_id = await _user(async_client, admin, "owner", ["queue:read_all", "queue:delete_own"])
  377. mine = await queue_item_factory(archive.id, printer.id, created_by_id=user_id)
  378. theirs = await queue_item_factory(archive.id, printer.id)
  379. mine_id, theirs_id = mine.id, theirs.id
  380. response = await async_client.post(f"{URL}/clear", headers=_auth(jwt))
  381. assert response.status_code == 200, response.text
  382. assert response.json() == {"cleared": 1, "kept": 0}
  383. db_session.expire_all()
  384. assert await db_session.get(PrintQueueItem, mine_id) is None
  385. assert await db_session.get(PrintQueueItem, theirs_id) is not None