| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030 |
- """Integration tests for batch orders — per-plate targets and staged dispatch (#342).
- The behaviour these lock down that the pre-#342 batch could not express: a
- failed or cancelled run does not satisfy a target, so the order keeps saying it
- owes a print until one actually completes.
- """
- from datetime import datetime
- import pytest
- from httpx import AsyncClient
- @pytest.fixture
- async def printer_factory(db_session):
- _counter = [0]
- async def _create_printer(**kwargs):
- from backend.app.models.printer import Printer
- _counter[0] += 1
- counter = _counter[0]
- defaults = {
- "name": f"Batch Printer {counter}",
- "ip_address": f"192.168.9.{100 + counter}",
- "serial_number": f"BATCHSERIAL{counter:04d}",
- "access_code": "12345678",
- "model": "X1C",
- }
- defaults.update(kwargs)
- printer = Printer(**defaults)
- db_session.add(printer)
- await db_session.commit()
- await db_session.refresh(printer)
- return printer
- return _create_printer
- @pytest.fixture
- async def archive_factory(db_session):
- _counter = [0]
- async def _create_archive(**kwargs):
- from backend.app.models.archive import PrintArchive
- _counter[0] += 1
- counter = _counter[0]
- defaults = {
- "filename": f"batch_order_{counter}.3mf",
- "print_name": f"Batch Order {counter}",
- "file_path": f"/tmp/batch_order_{counter}.3mf", # nosec B108
- "file_size": 2048,
- "content_hash": f"batchhash{counter:08d}",
- "status": "completed",
- }
- defaults.update(kwargs)
- archive = PrintArchive(**defaults)
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- return archive
- return _create_archive
- async def _create_order(async_client: AsyncClient, archive_id: int, plates: list[dict], **extra):
- payload = {"name": "Test Order", "archive_id": archive_id, "plates": plates}
- payload.update(extra)
- response = await async_client.post("/api/v1/queue/batches", json=payload)
- assert response.status_code == 200, response.text
- return response.json()
- async def _queue_item(async_client: AsyncClient, printer_id: int, archive_id: int, batch_id: int, **extra):
- payload = {"printer_id": printer_id, "archive_id": archive_id, "batch_id": batch_id}
- payload.update(extra)
- response = await async_client.post("/api/v1/queue/", json=payload)
- assert response.status_code == 200, response.text
- return response.json()
- async def _set_status(db_session, item_id: int, status: str):
- from backend.app.models.print_queue import PrintQueueItem
- item = await db_session.get(PrintQueueItem, item_id)
- item.status = status
- await db_session.commit()
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestBatchOrderTargets:
- async def test_order_reports_per_plate_targets(self, async_client, archive_factory):
- """The reporter's own example: plate 1 once, plate 2 twice, plate 3 three times."""
- archive = await archive_factory()
- order = await _create_order(
- async_client,
- archive.id,
- [
- {"plate_id": 1, "plate_name": "Base", "quantity_target": 1, "sort_order": 0},
- {"plate_id": 2, "quantity_target": 2, "sort_order": 1},
- {"plate_id": 3, "quantity_target": 3, "sort_order": 2},
- ],
- )
- assert order["has_targets"] is True
- assert order["target_count"] == 6
- assert order["remaining_count"] == 6
- assert [p["plate_id"] for p in order["plates"]] == [1, 2, 3]
- assert [p["quantity_target"] for p in order["plates"]] == [1, 2, 3]
- assert order["plates"][0]["plate_name"] == "Base"
- # Nothing dispatched yet, so nothing has been consumed.
- assert all(p["dispatched"] == 0 for p in order["plates"])
- async def test_zero_target_plate_is_allowed(self, async_client, archive_factory):
- """ "Plate 3 not required" keeps its row so it can be raised later."""
- archive = await archive_factory()
- order = await _create_order(
- async_client,
- archive.id,
- [{"plate_id": 1, "quantity_target": 2}, {"plate_id": 2, "quantity_target": 0}],
- )
- assert order["target_count"] == 2
- plate_two = next(p for p in order["plates"] if p["plate_id"] == 2)
- assert plate_two["quantity_target"] == 0
- assert plate_two["remaining"] == 0
- async def test_order_requesting_nothing_is_rejected(self, async_client, archive_factory):
- archive = await archive_factory()
- response = await async_client.post(
- "/api/v1/queue/batches",
- json={
- "name": "Empty",
- "archive_id": archive.id,
- "plates": [{"plate_id": 1, "quantity_target": 0}],
- },
- )
- assert response.status_code == 400
- assert "at least one print" in response.json()["detail"]
- async def test_duplicate_plate_is_rejected(self, async_client, archive_factory):
- archive = await archive_factory()
- response = await async_client.post(
- "/api/v1/queue/batches",
- json={
- "name": "Dupes",
- "archive_id": archive.id,
- "plates": [{"plate_id": 1, "quantity_target": 1}, {"plate_id": 1, "quantity_target": 2}],
- },
- )
- assert response.status_code == 400
- assert "Duplicate plate" in response.json()["detail"]
- async def test_duplicate_whole_file_plate_is_rejected(self, async_client, archive_factory):
- """NULL plate_id slips past the DB unique constraint, so the route must catch it."""
- archive = await archive_factory()
- response = await async_client.post(
- "/api/v1/queue/batches",
- json={
- "name": "Dupes",
- "archive_id": archive.id,
- "plates": [{"quantity_target": 1}, {"quantity_target": 2}],
- },
- )
- assert response.status_code == 400
- assert "whole file" in response.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestBatchOrderProgress:
- async def test_failed_run_leaves_the_work_owed(self, async_client, printer_factory, archive_factory, db_session):
- """The whole point of storing targets: a burned print is still owed."""
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
- first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _set_status(db_session, first["id"], "completed")
- await _set_status(db_session, second["id"], "failed")
- response = await async_client.get(f"/api/v1/queue/batches/{order['id']}")
- result = response.json()
- assert result["completed_count"] == 1
- assert result["failed_count"] == 1
- # One completed, one burned — the order still owes a print.
- assert result["remaining_count"] == 1
- assert result["status"] == "active"
- async def test_cancelled_run_also_leaves_the_work_owed(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
- item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _set_status(db_session, item["id"], "cancelled")
- result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
- assert result["cancelled_count"] == 1
- assert result["remaining_count"] == 1
- async def test_pending_and_printing_consume_the_target(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """In-flight work must not be re-dispatched — that would double-print."""
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
- first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _set_status(db_session, first["id"], "printing")
- result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
- assert result["printing_count"] == 1
- assert result["pending_count"] == 1
- assert result["remaining_count"] == 0
- async def test_legacy_batch_without_targets_owes_nothing(self, async_client, printer_factory, archive_factory):
- """Batches created before #342 keep working and report has_targets=false."""
- printer = await printer_factory()
- archive = await archive_factory()
- response = await async_client.post(
- "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 3}
- )
- batch_id = response.json()["batch_id"]
- result = (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()
- assert result["has_targets"] is False
- assert result["pending_count"] == 3
- assert result["remaining_count"] == 0
- assert result["target_count"] == 3
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestBatchOrderCompletion:
- async def test_status_flips_to_completed_when_targets_met(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
- item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _set_status(db_session, item["id"], "completed")
- # Reading the order re-evaluates it; the PATCH path and the print
- # completion hook do the same.
- patched = await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})
- assert patched.status_code == 200
- assert patched.json()["status"] == "completed"
- assert patched.json()["completed_at"] is not None
- async def test_raising_a_target_reopens_a_completed_order(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
- item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _set_status(db_session, item["id"], "completed")
- assert (await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})).json()[
- "status"
- ] == "completed"
- reopened = await async_client.patch(
- f"/api/v1/queue/batches/{order['id']}",
- json={"plates": [{"plate_id": 1, "quantity_target": 3}]},
- )
- assert reopened.status_code == 200
- assert reopened.json()["status"] == "active"
- assert reopened.json()["completed_at"] is None
- assert reopened.json()["remaining_count"] == 2
- async def test_legacy_batch_with_everything_cancelled_reads_as_cancelled(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """Cancelling every item of a grouping finishes it, but produces nothing.
- "Completed" would be a lie — its derived target is zero — and leaving it
- active would strand it on the Batches tab forever. Cancelled is what it
- is, and is what the batch-level Cancel action would have set.
- """
- printer = await printer_factory()
- archive = await archive_factory()
- response = await async_client.post(
- "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
- )
- batch_id = response.json()["batch_id"]
- from sqlalchemy import select
- from backend.app.models.print_queue import PrintQueueItem
- items = (
- (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id)))
- .scalars()
- .all()
- )
- for item in items:
- item.status = "cancelled"
- await db_session.commit()
- patched = await async_client.patch(f"/api/v1/queue/batches/{batch_id}", json={})
- assert patched.status_code == 200
- assert patched.json()["status"] == "cancelled"
- assert patched.json()["completed_at"] is None
- async def test_an_order_with_every_run_cancelled_still_owes_them(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """The grouping rule must not leak into orders.
- An order states its intent independently of its runs, so cancelling
- them all leaves it owing the work and offering to re-queue.
- """
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
- first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _set_status(db_session, first["id"], "cancelled")
- await _set_status(db_session, second["id"], "cancelled")
- patched = (await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})).json()
- assert patched["status"] == "active"
- assert patched["remaining_count"] == 2
- async def test_cancelled_order_is_never_resurrected(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
- item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
- await _set_status(db_session, item["id"], "completed")
- result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
- assert result["status"] == "cancelled"
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestBatchBacklog:
- """The Batches tab must not open on months of stale rows.
- `completed` only became a reachable status with #342, so every batch
- created since the feature shipped is still `active` however long ago its
- last run finished.
- """
- async def test_startup_backfill_closes_finished_batches(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- from backend.app.services.print_batch import backfill_batch_statuses
- printer = await printer_factory()
- archive = await archive_factory()
- response = await async_client.post(
- "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
- )
- batch_id = response.json()["batch_id"]
- from sqlalchemy import select
- from backend.app.models.print_queue import PrintQueueItem
- items = (
- (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id)))
- .scalars()
- .all()
- )
- for item in items:
- item.status = "completed"
- await db_session.commit()
- assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "active"
- changed = await backfill_batch_statuses(db_session)
- assert changed >= 1
- assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "completed"
- async def test_backfill_leaves_in_flight_batches_alone(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- from backend.app.services.print_batch import backfill_batch_statuses
- printer = await printer_factory()
- archive = await archive_factory()
- response = await async_client.post(
- "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
- )
- batch_id = response.json()["batch_id"]
- await backfill_batch_statuses(db_session)
- assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "active"
- async def test_backfill_is_idempotent(self, async_client, printer_factory, archive_factory, db_session):
- from backend.app.services.print_batch import backfill_batch_statuses
- printer = await printer_factory()
- archive = await archive_factory()
- response = await async_client.post(
- "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 1}
- )
- item_id = response.json()["id"]
- await _set_status(db_session, item_id, "completed")
- order = await _create_order(async_client, archive.id, [{"plate_id": 9, "quantity_target": 1}])
- first = await backfill_batch_statuses(db_session)
- second = await backfill_batch_statuses(db_session)
- assert second == 0, "a second pass must have nothing left to change"
- assert first >= 0
- # The untouched order owes work and stays active across both passes.
- assert (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()["status"] == "active"
- async def test_empty_shell_batches_are_not_listed(self, async_client, db_session):
- """A grouping whose items went with their archive has nothing to show."""
- from backend.app.models.print_batch import PrintBatch
- shell = PrintBatch(name="Orphaned grouping", quantity=1, status="active")
- db_session.add(shell)
- await db_session.commit()
- await db_session.refresh(shell)
- listed = (await async_client.get("/api/v1/queue/batches")).json()
- assert all(b["id"] != shell.id for b in listed)
- # Still addressable directly — only the list hides it.
- assert (await async_client.get(f"/api/v1/queue/batches/{shell.id}")).status_code == 200
- async def test_a_new_order_is_listed_before_its_first_dispatch(self, async_client, archive_factory):
- """Targets are enough to be worth showing — that is what it owes."""
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
- listed = (await async_client.get("/api/v1/queue/batches")).json()
- assert any(b["id"] == order["id"] for b in listed)
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestBatchOrderDispatch:
- async def test_dispatch_clones_the_print_configuration(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 2, "quantity_target": 3}])
- source = await _queue_item(
- async_client,
- printer.id,
- archive.id,
- order["id"],
- plate_id=2,
- timelapse=True,
- use_ams=False,
- bed_levelling="off",
- ams_mapping=[3, -1],
- )
- response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
- assert response.status_code == 200
- assert response.json()["remaining_count"] == 0
- assert response.json()["pending_count"] == 3
- from sqlalchemy import select
- from backend.app.models.print_queue import PrintQueueItem
- rows = (
- (
- await db_session.execute(
- select(PrintQueueItem).where(PrintQueueItem.batch_id == order["id"]).order_by(PrintQueueItem.id)
- )
- )
- .scalars()
- .all()
- )
- assert len(rows) == 3
- clones = [r for r in rows if r.id != source["id"]]
- for clone in clones:
- assert clone.plate_id == 2
- assert clone.printer_id == printer.id
- assert clone.timelapse is True
- assert clone.use_ams is False
- assert clone.bed_levelling == "off"
- assert clone.ams_mapping == "[3, -1]"
- assert clone.status == "pending"
- # Lifecycle state is not copied.
- assert clone.started_at is None
- assert clone.completed_at is None
- assert clone.dispatch_attempts == 0
- # Never replayed onto a clone: it would delete the source file out
- # from under the rest of the order.
- assert clone.cleanup_library_after_dispatch is False
- async def test_clones_land_in_their_own_printer_queue(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """Positions are per-printer sequences — a global MAX would scramble them."""
- printer_a = await printer_factory()
- printer_b = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(
- async_client,
- archive.id,
- [{"plate_id": 1, "quantity_target": 3}, {"plate_id": 2, "quantity_target": 2}],
- )
- # Pad printer B's queue so a global MAX would push plate 1's clones
- # past the end of printer A's much shorter queue.
- for _ in range(5):
- await async_client.post("/api/v1/queue/", json={"printer_id": printer_b.id, "archive_id": archive.id})
- await _queue_item(async_client, printer_a.id, archive.id, order["id"], plate_id=1)
- await _queue_item(async_client, printer_b.id, archive.id, order["id"], plate_id=2)
- response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
- assert response.status_code == 200
- from sqlalchemy import select
- from backend.app.models.print_queue import PrintQueueItem
- for printer in (printer_a, printer_b):
- rows = (
- (
- await db_session.execute(
- select(PrintQueueItem)
- .where(PrintQueueItem.printer_id == printer.id)
- .where(PrintQueueItem.status == "pending")
- )
- )
- .scalars()
- .all()
- )
- positions = sorted(r.position for r in rows)
- assert len(positions) == len(set(positions)), f"duplicate positions on printer {printer.id}"
- assert positions == list(range(1, len(rows) + 1)), f"gap in printer {printer.id} queue"
- async def test_clone_differs_from_its_source_only_in_lifecycle_state(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """Guard for future columns.
- A clone must carry every *setting* of the item it copies and reset
- every piece of *lifecycle* state. Adding a new setting column to
- PrintQueueItem without listing it in CLONED_SETTING_COLUMNS would make
- the second run of a plate behave differently from the first — silently,
- and on real hardware. This fails when that happens.
- """
- from sqlalchemy import inspect, select
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
- source_id = (
- await _queue_item(
- async_client,
- printer.id,
- archive.id,
- order["id"],
- plate_id=1,
- timelapse=True,
- use_ams=False,
- bed_levelling="off",
- flow_cali="on",
- vibration_cali=False,
- layer_inspect=True,
- gcode_injection=True,
- auto_off_after=True,
- require_previous_success=True,
- )
- )["id"]
- # Dirty the source with scheduler state that must not be inherited.
- source = await db_session.get(PrintQueueItem, source_id)
- source.dispatch_attempts = 3
- source.been_jumped = True
- source.gate_acknowledged = True
- source.filament_short = True
- source.waiting_reason = "no idle printer"
- source.error_message = "previous failure"
- source.scheduled_time = datetime(2026, 1, 1, 12, 0, 0)
- await db_session.commit()
- assert (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).status_code == 200
- clone = (
- (
- await db_session.execute(
- select(PrintQueueItem)
- .where(PrintQueueItem.batch_id == order["id"])
- .where(PrintQueueItem.id != source_id)
- )
- )
- .scalars()
- .one()
- )
- # Every column that is neither identity, ordering, nor deliberately reset
- # must match the source exactly.
- reset_on_clone = {
- "status",
- "waiting_reason",
- "been_jumped",
- "dispatch_attempts",
- "dispatching_at",
- "gate_acknowledged",
- "filament_short",
- "error_message",
- "started_at",
- "completed_at",
- "scheduled_time",
- "cleanup_library_after_dispatch",
- }
- identity = {"id", "created_at", "position"}
- await db_session.refresh(source)
- for column in (c.key for c in inspect(PrintQueueItem).mapper.column_attrs):
- if column in identity or column in reset_on_clone:
- continue
- assert getattr(clone, column) == getattr(source, column), (
- f"{column} was not carried onto the clone — a new setting column probably needs adding to "
- "CLONED_SETTING_COLUMNS"
- )
- assert clone.status == "pending"
- assert clone.dispatch_attempts == 0
- assert clone.been_jumped is False
- assert clone.gate_acknowledged is False
- assert clone.filament_short is False
- assert clone.waiting_reason is None
- assert clone.error_message is None
- assert clone.started_at is None and clone.completed_at is None
- # "Queue the rest now" must not replay a moment chosen for a different print.
- assert clone.scheduled_time is None
- # Would delete the source file out from under the rest of the order.
- assert clone.cleanup_library_after_dispatch is False
- async def test_dispatch_respects_limit(self, async_client, printer_factory, archive_factory):
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 10}])
- await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={"limit": 4})).json()
- assert result["pending_count"] == 5 # the original plus four
- assert result["remaining_count"] == 5
- async def test_dispatch_can_target_a_single_plate(self, async_client, printer_factory, archive_factory):
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(
- async_client,
- archive.id,
- [{"plate_id": 1, "quantity_target": 2}, {"plate_id": 2, "quantity_target": 2}],
- )
- await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=2)
- result = (
- await async_client.post(
- f"/api/v1/queue/batches/{order['id']}/dispatch",
- json={"plate_id": 2, "only_plate": True},
- )
- ).json()
- plate_one = next(p for p in result["plates"] if p["plate_id"] == 1)
- plate_two = next(p for p in result["plates"] if p["plate_id"] == 2)
- assert plate_one["remaining"] == 1
- assert plate_two["remaining"] == 0
- async def test_dispatch_without_a_reference_item_is_rejected(self, async_client, archive_factory):
- """Nothing to clone means no configuration to copy — say so, don't guess."""
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 4, "quantity_target": 2}])
- response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
- assert response.status_code == 400
- assert "no queued or finished run" in response.json()["detail"]
- async def test_one_stranded_plate_does_not_block_the_others(self, async_client, printer_factory, archive_factory):
- """A plate with nothing to clone is skipped, not an abort for the whole order (#2960)."""
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(
- async_client,
- archive.id,
- [{"plate_id": 1, "quantity_target": 2}, {"plate_id": 2, "quantity_target": 2}],
- )
- # Plate 2 never gets an item, so only plate 1 can be cloned.
- await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
- assert response.status_code == 200, response.text
- result = response.json()
- plate_one = next(p for p in result["plates"] if p["plate_id"] == 1)
- plate_two = next(p for p in result["plates"] if p["plate_id"] == 2)
- assert plate_one["remaining"] == 0
- assert plate_two["remaining"] == 2
- # The order still owes plate 2 but reports that none of it is queueable.
- assert result["remaining_count"] == 2
- assert result["dispatchable_count"] == 0
- assert plate_one["can_dispatch"] is False # nothing left owing
- assert plate_two["can_dispatch"] is False # owing, but nothing to clone
- async def test_dispatching_a_stranded_plate_by_name_still_fails_loudly(
- self, async_client, printer_factory, archive_factory
- ):
- """An explicit single-plate request must not silently do nothing."""
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(
- async_client,
- archive.id,
- [
- {"plate_id": 1, "quantity_target": 1},
- {"plate_id": 2, "plate_name": "Side rail.stl", "quantity_target": 1},
- ],
- )
- await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- response = await async_client.post(
- f"/api/v1/queue/batches/{order['id']}/dispatch",
- json={"plate_id": 2, "only_plate": True},
- )
- assert response.status_code == 400
- # Named by the label the Batches tab shows, not by a bare index.
- assert "Side rail.stl" in response.json()["detail"]
- async def test_can_dispatch_is_true_while_a_source_survives(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """A cancelled run is still a clone source — the plate stays queueable."""
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
- item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _set_status(db_session, item["id"], "cancelled")
- result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
- assert result["remaining_count"] == 2
- assert result["dispatchable_count"] == 2
- assert result["plates"][0]["can_dispatch"] is True
- async def test_dispatch_on_legacy_batch_is_a_noop(self, async_client, printer_factory, archive_factory):
- printer = await printer_factory()
- archive = await archive_factory()
- response = await async_client.post(
- "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
- )
- batch_id = response.json()["batch_id"]
- result = (await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={})).json()
- assert result["pending_count"] == 2
- async def test_cannot_dispatch_a_cancelled_order(self, async_client, printer_factory, archive_factory):
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
- await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
- response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
- assert response.status_code == 400
- assert "cancelled" in response.json()["detail"]
- async def test_redispatch_after_failure_replaces_the_burned_run(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """End to end: 2 wanted, 1 completes, 1 fails, dispatch queues the replacement."""
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
- first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _set_status(db_session, first["id"], "completed")
- await _set_status(db_session, second["id"], "failed")
- result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).json()
- assert result["pending_count"] == 1
- assert result["remaining_count"] == 0
- assert result["status"] == "active"
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestBatchOrderHeader:
- async def test_header_fields_round_trip(self, async_client, archive_factory):
- archive = await archive_factory()
- order = await _create_order(
- async_client,
- archive.id,
- [{"plate_id": 1, "quantity_target": 1}],
- due_date="2026-09-01T12:00:00",
- notes="Rush job",
- )
- assert order["notes"] == "Rush job"
- assert order["due_date"].startswith("2026-09-01T12:00:00")
- patched = (
- await async_client.patch(
- f"/api/v1/queue/batches/{order['id']}", json={"name": "Renamed", "notes": "Updated"}
- )
- ).json()
- assert patched["name"] == "Renamed"
- assert patched["notes"] == "Updated"
- async def test_unknown_project_is_rejected(self, async_client, archive_factory):
- archive = await archive_factory()
- response = await async_client.post(
- "/api/v1/queue/batches",
- json={
- "name": "Order",
- "archive_id": archive.id,
- "project_id": 999999,
- "plates": [{"plate_id": 1, "quantity_target": 1}],
- },
- )
- assert response.status_code == 404
- async def test_patch_replaces_the_target_set(self, async_client, archive_factory):
- """A plate omitted from the payload has its target row removed."""
- archive = await archive_factory()
- order = await _create_order(
- async_client,
- archive.id,
- [{"plate_id": 1, "quantity_target": 1}, {"plate_id": 2, "quantity_target": 1}],
- )
- patched = (
- await async_client.patch(
- f"/api/v1/queue/batches/{order['id']}",
- json={"plates": [{"plate_id": 1, "quantity_target": 5}]},
- )
- ).json()
- assert [p["plate_id"] for p in patched["plates"]] == [1]
- assert patched["target_count"] == 5
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestBatchOrderCost:
- async def test_cost_rolls_up_from_logged_runs(self, async_client, printer_factory, archive_factory, db_session):
- """Cost is attributed through queue_item_id, not guessed from the archive."""
- from backend.app.models.print_log import PrintLogEntry
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 4}])
- first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _set_status(db_session, first["id"], "completed")
- await _set_status(db_session, second["id"], "completed")
- db_session.add(
- PrintLogEntry(
- archive_id=archive.id,
- queue_item_id=first["id"],
- status="completed",
- cost=2.0,
- energy_cost=0.5,
- filament_used_grams=40.0,
- )
- )
- db_session.add(
- PrintLogEntry(
- archive_id=archive.id,
- queue_item_id=second["id"],
- status="completed",
- cost=3.0,
- energy_cost=0.5,
- filament_used_grams=60.0,
- )
- )
- # A run of the same archive that has nothing to do with this order.
- db_session.add(PrintLogEntry(archive_id=archive.id, queue_item_id=None, status="completed", cost=99.0))
- await db_session.commit()
- result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
- assert result["actual_cost"] == pytest.approx(6.0)
- assert result["filament_used_grams"] == pytest.approx(100.0)
- # Two completed at 3.00 each, two still owed.
- assert result["estimated_remaining_cost"] == pytest.approx(6.0)
- async def test_cost_is_unknown_not_zero_before_the_first_run(self, async_client, printer_factory, archive_factory):
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
- await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
- assert result["actual_cost"] is None
- assert result["estimated_remaining_cost"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestOrderSourcePreservation:
- """Deleting an order's last run for a plate must not strand it (#2960).
- Dispatch produces what an order still owes by cloning an existing queue
- item. Hard-deleting the last one left the order reporting work outstanding
- with nothing able to produce it, and no way to close it out either.
- """
- async def test_last_run_for_a_plate_is_cancelled_not_deleted(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
- item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- response = await async_client.delete(f"/api/v1/queue/{item['id']}")
- assert response.status_code == 200
- assert response.json()["deleted"] is False
- db_session.expire_all()
- survivor = await db_session.get(PrintQueueItem, item["id"])
- assert survivor is not None
- assert survivor.status == "cancelled"
- async def test_the_order_can_still_be_dispatched_afterwards(self, async_client, printer_factory, archive_factory):
- """The whole point: #2960's stuck card, end to end."""
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
- item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await async_client.delete(f"/api/v1/queue/{item['id']}")
- # A cancelled run does not consume a target, so the order owes both.
- state = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
- assert state["remaining_count"] == 2
- assert state["dispatchable_count"] == 2
- result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).json()
- assert result["pending_count"] == 2
- assert result["remaining_count"] == 0
- async def test_a_run_with_a_sibling_is_really_deleted(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """Only the *last* source is protected — nothing else changes."""
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
- first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- response = await async_client.delete(f"/api/v1/queue/{first['id']}")
- assert response.json()["deleted"] is True
- db_session.expire_all()
- assert await db_session.get(PrintQueueItem, first["id"]) is None
- async def test_a_completed_run_is_never_rewritten_as_cancelled(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """Deleting history is the user's call; falsifying it is not."""
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
- item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await _set_status(db_session, item["id"], "completed")
- response = await async_client.delete(f"/api/v1/queue/{item['id']}")
- assert response.json()["deleted"] is True
- db_session.expire_all()
- assert await db_session.get(PrintQueueItem, item["id"]) is None
- async def test_a_grouping_without_targets_deletes_as_before(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """A grouping owes nothing, so nothing about it can be stranded."""
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory()
- response = await async_client.post(
- "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
- )
- items = response.json()
- first = items[0] if isinstance(items, list) else items
- deleted = await async_client.delete(f"/api/v1/queue/{first['id']}")
- assert deleted.json()["deleted"] is True
- db_session.expire_all()
- assert await db_session.get(PrintQueueItem, first["id"]) is None
- async def test_a_cancelled_order_lets_its_leftover_rows_be_deleted(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """Nothing to protect once the order is closed — and tidying up is why you delete."""
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
- item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
- await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
- deleted = await async_client.delete(f"/api/v1/queue/{item['id']}")
- assert deleted.json()["deleted"] is True
- db_session.expire_all()
- assert await db_session.get(PrintQueueItem, item["id"]) is None
- async def test_a_plate_the_order_has_no_target_for_deletes_as_before(
- self, async_client, printer_factory, archive_factory, db_session
- ):
- """Grouped in by hand after the fact: it owes nothing, so delete means delete."""
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory()
- order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
- stray = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=7)
- deleted = await async_client.delete(f"/api/v1/queue/{stray['id']}")
- assert deleted.json()["deleted"] is True
- db_session.expire_all()
- assert await db_session.get(PrintQueueItem, stray["id"]) is None
|