| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476 |
- """Queue history, paged on the server (GET /queue/history, POST /queue/history/clear).
- The Queue page used to load the whole queue every five seconds and split off
- the history in the browser. On a print farm that was thousands of rows and
- stalled the server for over a minute. These tests pin that the server-side
- version shows the same rows, in the same order, under the same filters, and
- that clearing it keeps the rule the one-by-one DELETEs followed (#2960).
- """
- from __future__ import annotations
- from datetime import datetime, timedelta
- import pytest
- from httpx import AsyncClient
- pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
- URL = "/api/v1/queue/history"
- T0 = datetime(2026, 10, 1, 12, 0)
- @pytest.fixture
- def queue_item_factory(db_session):
- position = [0]
- async def _create(archive_id: int | None, printer_id: int | None = None, **kwargs):
- from backend.app.models.print_queue import PrintQueueItem
- position[0] += 1
- defaults = {
- "archive_id": archive_id,
- "printer_id": printer_id,
- "status": "completed",
- "position": position[0],
- "created_at": T0,
- }
- defaults.update(kwargs)
- item = PrintQueueItem(**defaults)
- db_session.add(item)
- await db_session.commit()
- await db_session.refresh(item)
- return item
- return _create
- async def _ids(async_client: AsyncClient, **params) -> list[int]:
- response = await async_client.get(URL, params=params)
- assert response.status_code == 200, response.text
- return [row["id"] for row in response.json()["items"]]
- class TestListing:
- async def test_only_history_statuses(self, async_client, printer_factory, archive_factory, queue_item_factory):
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- history = [
- await queue_item_factory(archive.id, printer.id, status=status)
- for status in ("completed", "failed", "skipped", "cancelled")
- ]
- await queue_item_factory(archive.id, printer.id, status="pending")
- await queue_item_factory(archive.id, printer.id, status="printing")
- response = await async_client.get(URL)
- assert response.status_code == 200, response.text
- assert sorted(row["id"] for row in response.json()["items"]) == sorted(i.id for i in history)
- assert response.json()["total"] == 4
- async def test_newest_first_by_completion_then_creation(
- self, async_client, printer_factory, archive_factory, queue_item_factory
- ):
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- old = await queue_item_factory(archive.id, printer.id, completed_at=T0)
- never_finished = await queue_item_factory(
- archive.id, printer.id, status="cancelled", created_at=T0 + timedelta(hours=2)
- )
- new = await queue_item_factory(archive.id, printer.id, completed_at=T0 + timedelta(hours=3))
- assert await _ids(async_client) == [new.id, never_finished.id, old.id]
- assert await _ids(async_client, reverse=True) == [old.id, never_finished.id, new.id]
- async def test_sort_by_name_is_a_to_z_and_ignores_case(
- self, async_client, printer_factory, archive_factory, queue_item_factory
- ):
- printer = await printer_factory()
- names = {}
- for name in ("bracket", "Anchor", "Clip"):
- archive = await archive_factory(printer.id, print_name=name)
- names[name] = (await queue_item_factory(archive.id, printer.id)).id
- assert await _ids(async_client, sort_by="name") == [names["Anchor"], names["bracket"], names["Clip"]]
- assert await _ids(async_client, sort_by="name", reverse=True) == [
- names["Clip"],
- names["bracket"],
- names["Anchor"],
- ]
- async def test_sort_by_name_skips_a_deleted_archive(
- self, async_client, printer_factory, archive_factory, queue_item_factory
- ):
- # A deleted archive's name isn't shown on the row, so it can't order it
- printer = await printer_factory()
- gone = await archive_factory(printer.id, print_name="Aaa", filename="zzz.3mf", deleted_at=T0)
- live = await archive_factory(printer.id, print_name="Mmm")
- gone_row = await queue_item_factory(gone.id, printer.id)
- live_row = await queue_item_factory(live.id, printer.id)
- # No archive and no library file: sorts as an empty name, first
- assert await _ids(async_client, sort_by="name") == [gone_row.id, live_row.id]
- async def test_sort_by_printer(self, async_client, printer_factory, archive_factory, queue_item_factory):
- zeta, alpha = await printer_factory(name="Zeta"), await printer_factory(name="alpha")
- archive = await archive_factory(zeta.id)
- on_zeta = await queue_item_factory(archive.id, zeta.id)
- on_alpha = await queue_item_factory(archive.id, alpha.id)
- assert await _ids(async_client, sort_by="printer") == [on_alpha.id, on_zeta.id]
- async def test_pages_with_total(self, async_client, printer_factory, archive_factory, queue_item_factory):
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- rows = [
- await queue_item_factory(archive.id, printer.id, completed_at=T0 + timedelta(minutes=i)) for i in range(5)
- ]
- newest_first = [r.id for r in reversed(rows)]
- response = await async_client.get(URL, params={"limit": 2, "offset": 2})
- assert response.status_code == 200, response.text
- assert [row["id"] for row in response.json()["items"]] == newest_first[2:4]
- assert response.json()["total"] == 5
- async def test_page_brings_the_other_runs_of_its_batches(
- self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
- ):
- from backend.app.models.print_batch import PrintBatch
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- batch = PrintBatch(name="Order", status="active")
- db_session.add(batch)
- await db_session.commit()
- older_run = await queue_item_factory(archive.id, printer.id, batch_id=batch.id, completed_at=T0)
- await queue_item_factory(archive.id, printer.id, completed_at=T0 + timedelta(hours=1))
- newer_run = await queue_item_factory(
- archive.id, printer.id, batch_id=batch.id, completed_at=T0 + timedelta(hours=2)
- )
- response = await async_client.get(URL, params={"limit": 1})
- assert response.status_code == 200, response.text
- # The page first, then the rest of its batch; the unbatched row stays out
- assert [row["id"] for row in response.json()["items"]] == [newer_run.id, older_run.id]
- assert response.json()["total"] == 3
- class TestFilters:
- async def test_printer_and_unassigned(self, async_client, printer_factory, archive_factory, queue_item_factory):
- a, b = await printer_factory(), await printer_factory()
- archive = await archive_factory(a.id)
- on_a = await queue_item_factory(archive.id, a.id)
- await queue_item_factory(archive.id, b.id)
- unassigned = await queue_item_factory(archive.id, None, status="cancelled", target_model="P2S")
- assert await _ids(async_client, printer_id=a.id) == [on_a.id]
- assert await _ids(async_client, printer_id=-1) == [unassigned.id]
- async def test_status(self, async_client, printer_factory, archive_factory, queue_item_factory):
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- failed = await queue_item_factory(archive.id, printer.id, status="failed")
- await queue_item_factory(archive.id, printer.id, status="completed")
- await queue_item_factory(archive.id, printer.id, status="pending")
- assert await _ids(async_client, status="failed") == [failed.id]
- # An active status selected on the page leaves no history to show
- assert await _ids(async_client, status="pending") == []
- async def test_location_by_target_location_or_printer(
- self, async_client, printer_factory, archive_factory, queue_item_factory
- ):
- hall, lab = await printer_factory(location="Hall"), await printer_factory(location="Lab")
- archive = await archive_factory(hall.id)
- in_hall = await queue_item_factory(archive.id, hall.id)
- await queue_item_factory(archive.id, lab.id)
- model_based_hall = await queue_item_factory(archive.id, None, target_model="P2S", target_location="Hall")
- # A target location wins over the printer's own
- await queue_item_factory(archive.id, hall.id, target_location="Lab")
- assert sorted(await _ids(async_client, location="Hall")) == sorted([in_hall.id, model_based_hall.id])
- async def test_location_filter_with_printer_sort(
- self, async_client, printer_factory, archive_factory, queue_item_factory
- ):
- # The printer sort joins printers; the location check must not get tangled in it
- hall, lab = await printer_factory(name="B", location="Hall"), await printer_factory(name="A", location="Lab")
- archive = await archive_factory(hall.id)
- in_hall = await queue_item_factory(archive.id, hall.id)
- await queue_item_factory(archive.id, lab.id)
- assert await _ids(async_client, location="Hall", sort_by="printer") == [in_hall.id]
- async def test_locations_lists_every_target_location(
- self, async_client, printer_factory, archive_factory, queue_item_factory
- ):
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- await queue_item_factory(archive.id, None, target_model="P2S", target_location="Hall")
- await queue_item_factory(archive.id, None, target_model="P2S", target_location="Annex")
- await queue_item_factory(archive.id, None, target_model="P2S", target_location="")
- await queue_item_factory(archive.id, printer.id)
- response = await async_client.get(URL, params={"status": "failed", "limit": 1})
- assert response.status_code == 200, response.text
- # Independent of the filters and the page
- assert response.json()["locations"] == ["Annex", "Hall"]
- class TestActiveListing:
- async def test_list_takes_several_statuses(
- self, async_client, printer_factory, archive_factory, queue_item_factory
- ):
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- pending = await queue_item_factory(archive.id, printer.id, status="pending")
- printing = await queue_item_factory(archive.id, printer.id, status="printing")
- await queue_item_factory(archive.id, printer.id, status="completed")
- response = await async_client.get("/api/v1/queue/", params={"status": "pending,printing"})
- assert response.status_code == 200, response.text
- assert sorted(row["id"] for row in response.json()) == sorted([pending.id, printing.id])
- async def test_list_single_status_unchanged(
- self, async_client, printer_factory, archive_factory, queue_item_factory
- ):
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- pending = await queue_item_factory(archive.id, printer.id, status="pending")
- await queue_item_factory(archive.id, printer.id, status="completed")
- response = await async_client.get("/api/v1/queue/", params={"status": "pending"})
- assert [row["id"] for row in response.json()] == [pending.id]
- class TestClear:
- async def test_clears_only_history_under_the_filters(
- self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
- ):
- from backend.app.models.print_queue import PrintQueueItem
- a, b = await printer_factory(), await printer_factory()
- archive = await archive_factory(a.id)
- done_on_a = await queue_item_factory(archive.id, a.id)
- failed_on_a = await queue_item_factory(archive.id, a.id, status="failed")
- done_on_b = await queue_item_factory(archive.id, b.id)
- pending_on_a = await queue_item_factory(archive.id, a.id, status="pending")
- ids = {r: r.id for r in (done_on_a, failed_on_a, done_on_b, pending_on_a)}
- response = await async_client.post(f"{URL}/clear", params={"printer_id": a.id})
- assert response.status_code == 200, response.text
- assert response.json() == {"cleared": 2, "kept": 0}
- db_session.expire_all()
- assert await db_session.get(PrintQueueItem, ids[done_on_a]) is None
- assert await db_session.get(PrintQueueItem, ids[failed_on_a]) is None
- assert await db_session.get(PrintQueueItem, ids[done_on_b]) is not None
- assert await db_session.get(PrintQueueItem, ids[pending_on_a]) is not None
- async def test_keeps_an_orders_last_run_as_cancelled(
- self, async_client, db_session, printer_factory, archive_factory
- ):
- """The same rule as deleting the run on its own (#2960)."""
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- order = await async_client.post(
- "/api/v1/queue/batches",
- json={"name": "Order", "archive_id": archive.id, "plates": [{"plate_id": 1, "quantity_target": 3}]},
- )
- assert order.status_code == 200, order.text
- runs = []
- for _ in range(2):
- created = await async_client.post(
- "/api/v1/queue/",
- json={
- "printer_id": printer.id,
- "archive_id": archive.id,
- "batch_id": order.json()["id"],
- "plate_id": 1,
- },
- )
- assert created.status_code == 200, created.text
- runs.append(created.json()["id"])
- for run_id in runs:
- row = await db_session.get(PrintQueueItem, run_id)
- row.status = "failed"
- await db_session.commit()
- response = await async_client.post(f"{URL}/clear")
- assert response.status_code == 200, response.text
- assert response.json() == {"cleared": 1, "kept": 1}
- db_session.expire_all()
- # The later of the two, in queue order, is the one left to clone
- assert await db_session.get(PrintQueueItem, runs[0]) is None
- survivor = await db_session.get(PrintQueueItem, runs[1])
- assert survivor is not None
- assert survivor.status == "cancelled"
- # Ended now, as a cancel on its own stamps it
- assert survivor.completed_at is not None
- async def test_a_grouping_whose_last_open_job_is_cleared_is_finished(
- self, async_client, db_session, printer_factory, archive_factory
- ):
- """As deleting that job on its own finishes it."""
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- ids = []
- for _ in range(2):
- created = await async_client.post(
- "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id}
- )
- ids.append(created.json()["id"])
- grouping = (await async_client.post("/api/v1/queue/batches", json={"name": "G", "item_ids": ids})).json()
- assert (await async_client.post(f"/api/v1/queue/{ids[0]}/cancel")).status_code == 200
- # Skipped is still open: the grouping waits for it
- row = await db_session.get(PrintQueueItem, ids[1])
- row.status = "skipped"
- await db_session.commit()
- response = await async_client.post(f"{URL}/clear", params={"status": "skipped"})
- assert response.json() == {"cleared": 1, "kept": 0}
- batch = await async_client.get(f"/api/v1/queue/batches/{grouping['id']}")
- assert batch.json()["status"] == "cancelled"
- def _auth(jwt: str) -> dict[str, str]:
- return {"Authorization": f"Bearer {jwt}"}
- async def _admin_token(async_client: AsyncClient) -> str:
- await async_client.post(
- "/api/v1/auth/setup",
- json={"auth_enabled": True, "admin_username": "histadmin", "admin_password": "AdminPass1!"},
- )
- login = await async_client.post("/api/v1/auth/login", json={"username": "histadmin", "password": "AdminPass1!"})
- assert login.status_code == 200, login.text
- return login.json()["access_token"]
- async def _user(
- async_client: AsyncClient, admin_jwt: str, username: str, permissions: list[str], printer_ids=None
- ) -> tuple[str, int]:
- groups = []
- perms = await async_client.post(
- "/api/v1/groups/", headers=_auth(admin_jwt), json={"name": f"perms_{username}", "permissions": permissions}
- )
- assert perms.status_code == 201, perms.text
- groups.append(perms.json()["id"])
- if printer_ids is not None:
- team = await async_client.post(
- "/api/v1/groups/",
- headers=_auth(admin_jwt),
- json={"name": f"team_{username}", "permissions": [], "restrict_printers": True, "printer_ids": printer_ids},
- )
- assert team.status_code == 201, team.text
- groups.append(team.json()["id"])
- created = await async_client.post(
- "/api/v1/users/",
- headers=_auth(admin_jwt),
- json={"username": username, "password": "UserPass1!", "group_ids": groups},
- )
- assert created.status_code in (200, 201), created.text
- login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
- assert login.status_code == 200, login.text
- return login.json()["access_token"], created.json()["id"]
- class TestAccess:
- async def test_read_own_sees_only_own_history(
- self, async_client, printer_factory, archive_factory, queue_item_factory
- ):
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- admin = await _admin_token(async_client)
- jwt, user_id = await _user(async_client, admin, "owner", ["queue:read_own"])
- mine = await queue_item_factory(archive.id, printer.id, created_by_id=user_id)
- await queue_item_factory(archive.id, printer.id)
- response = await async_client.get(URL, headers=_auth(jwt))
- assert response.status_code == 200, response.text
- assert [row["id"] for row in response.json()["items"]] == [mine.id]
- assert response.json()["total"] == 1
- async def test_team_member_sees_only_team_printers(
- self, async_client, printer_factory, archive_factory, queue_item_factory
- ):
- a, b = await printer_factory(), await printer_factory()
- archive = await archive_factory(a.id)
- admin = await _admin_token(async_client)
- jwt, _ = await _user(async_client, admin, "member", ["queue:read_all"], printer_ids=[a.id])
- on_a = await queue_item_factory(archive.id, a.id)
- await queue_item_factory(archive.id, b.id)
- response = await async_client.get(URL, headers=_auth(jwt))
- assert [row["id"] for row in response.json()["items"]] == [on_a.id]
- async def test_needs_queue_read(self, async_client):
- admin = await _admin_token(async_client)
- jwt, _ = await _user(async_client, admin, "nobody", ["printers:read"])
- assert (await async_client.get(URL, headers=_auth(jwt))).status_code == 403
- assert (await async_client.post(f"{URL}/clear", headers=_auth(jwt))).status_code == 403
- async def test_read_own_clears_only_own_history_even_with_delete_all(
- self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
- ):
- # The page only ever listed, and so deleted, the rows its user could see
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- admin = await _admin_token(async_client)
- jwt, user_id = await _user(async_client, admin, "reader", ["queue:read_own", "queue:delete_all"])
- mine = await queue_item_factory(archive.id, printer.id, created_by_id=user_id)
- theirs = await queue_item_factory(archive.id, printer.id)
- mine_id, theirs_id = mine.id, theirs.id
- response = await async_client.post(f"{URL}/clear", headers=_auth(jwt))
- assert response.status_code == 200, response.text
- assert response.json() == {"cleared": 1, "kept": 0}
- db_session.expire_all()
- assert await db_session.get(PrintQueueItem, mine_id) is None
- assert await db_session.get(PrintQueueItem, theirs_id) is not None
- async def test_clear_needs_queue_read(self, async_client):
- admin = await _admin_token(async_client)
- jwt, _ = await _user(async_client, admin, "deleter", ["queue:delete_all"])
- assert (await async_client.post(f"{URL}/clear", headers=_auth(jwt))).status_code == 403
- async def test_delete_own_clears_only_own_history(
- self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
- ):
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- admin = await _admin_token(async_client)
- jwt, user_id = await _user(async_client, admin, "owner", ["queue:read_all", "queue:delete_own"])
- mine = await queue_item_factory(archive.id, printer.id, created_by_id=user_id)
- theirs = await queue_item_factory(archive.id, printer.id)
- mine_id, theirs_id = mine.id, theirs.id
- response = await async_client.post(f"{URL}/clear", headers=_auth(jwt))
- assert response.status_code == 200, response.text
- assert response.json() == {"cleared": 1, "kept": 0}
- db_session.expire_all()
- assert await db_session.get(PrintQueueItem, mine_id) is None
- assert await db_session.get(PrintQueueItem, theirs_id) is not None
|