fix(queue): persist selected plate to the archive; reconcile archive on offline stop (#2603)

A print queued from a specific plate of a multi-plate 3MF showed as Plate 1
in Print History after cancellation: the archive derives its plate from the
filename, but a whole multi-plate 3MF uploads under one name with no plate
suffix, so the parser defaulted to plate 1 and nothing copied the queue
item's plate_id onto the archive (which had no plate field).

Add a nullable print_archives.plate_id, copy it from the queue item at
dispatch (archive- and library-file paths), expose it in the archive API,
and render it in Print History. A startup backfill copies the plate onto
existing archives from their linked queue rows. Column add + backfill are
identical on SQLite and Postgres.

Also fix a related lifecycle bug: stopping a printing item while the printer
was offline left the linked archive stuck at "printing" (queue row
cancelled, but no MQTT completion ever arrives to reconcile the archive).
The offline-stop path now closes the archive out directly; the online path
still defers to the MQTT completion event.
This commit is contained in:
maziggy
2026-07-19 09:29:54 +02:00
parent 4a0b14ed0e
commit 83a7b75b14
14 changed files with 381 additions and 3 deletions
@@ -3164,3 +3164,75 @@ class TestForceColorOverridesAreScopedToThePlate:
)
assert response.status_code == 200
assert [o["color_name"] for o in response.json()["filament_overrides"]] == ["Sunshine Yellow"]
@pytest.mark.asyncio
async def test_stop_offline_reconciles_linked_archive_status_2603(
async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Stopping a printing item while the printer is offline must also close out its
archive (#2603).
When the stop command reaches the printer, the later MQTT completion event flips
the archive to cancelled. When the printer is offline no such event ever arrives,
so without this the archive stays "printing" forever while the queue row is
already cancelled — the reporter's archive 436. The offline branch reconciles the
archive directly.
"""
from unittest.mock import MagicMock, patch
from backend.app.models.print_queue import PrintQueueItem
printer = await printer_factory(name="Offline printer")
archive = await archive_factory(
printer.id, status="printing", plate_id=22, filename="heart 3.gcode.3mf", with_run=False
)
item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing")
db_session.add(item)
await db_session.commit()
await db_session.refresh(item)
# stop_print returns False => printer offline / not connected.
with patch(
"backend.app.services.printer_manager.printer_manager.stop_print",
MagicMock(return_value=False),
):
resp = await async_client.post(f"/api/v1/queue/{item.id}/stop")
assert resp.status_code == 200
await db_session.refresh(item)
await db_session.refresh(archive)
assert item.status == "cancelled"
assert archive.status == "cancelled", "an offline stop must reconcile the archive, not leave it 'printing'"
assert archive.completed_at is not None
assert archive.failure_reason == "Stopped by user (printer was offline)"
@pytest.mark.asyncio
async def test_stop_online_leaves_archive_for_mqtt_to_reconcile_2603(
async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""When the stop command reaches the printer, the archive is left to the MQTT
completion path — the offline reconcile must NOT fire and pre-empt it."""
from unittest.mock import MagicMock, patch
from backend.app.models.print_queue import PrintQueueItem
printer = await printer_factory(name="Online printer")
archive = await archive_factory(printer.id, status="printing", filename="heart 3.gcode.3mf", with_run=False)
item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing")
db_session.add(item)
await db_session.commit()
await db_session.refresh(item)
with patch(
"backend.app.services.printer_manager.printer_manager.stop_print",
MagicMock(return_value=True),
):
resp = await async_client.post(f"/api/v1/queue/{item.id}/stop")
assert resp.status_code == 200
await db_session.refresh(item)
await db_session.refresh(archive)
assert item.status == "cancelled"
assert archive.status == "printing", "an online stop must leave the archive for the MQTT completion path"