mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
fix(queue): cancel pending items and hide stale archive surface when archive soft-deleted (#1348)
Opening the Print Queue page fired 404s on /archives/{id}/thumbnail,
/archives/{id}/plates, and /archives/{id}/plate-thumbnail/{n} for any
row pointing at a soft-deleted archive. Two underlying problems wearing
one mask:
1. Cosmetic: the queue API was copying item.archive.thumbnail_path into
archive_thumbnail without checking deleted_at. Soft-delete leaves the
row (so the relationship resolves) but removes the file from disk, so
the cached path was always stale.
2. Functional: a queue item whose 3MF was removed can never dispatch.
Without an explicit cancel, the item sits in 'pending' forever with
no indication to the user about why nothing is printing.
Fix in three parts:
- New _cancel_pending_queue_items() helper, called from soft_delete_archive
alongside the existing print-log thumbnail cleanup. Sets status='cancelled'
+ waiting_reason='Source archive deleted' on every pending queue item
linked to the archive. Only 'pending' is touched - completed/failed/
cancelled rows are historical and untouched. Hard-delete is already
covered by ON DELETE CASCADE on print_queue.archive_id.
- Queue API serializer now checks item.archive.deleted_at before
populating any archive-derived field. New archive_deleted: bool field
on PrintQueueItemResponse signals the soft-deleted state.
- Frontend's getArchivePlates query in QueuePage was gated on archive_id
only - archive_id is the real FK and stays exposed for dispatch/audit,
so added an explicit && !item.archive_deleted clause to respect the
new flag. Thumbnail render and CompactHistoryRow/QueueTimelineView
already gate on archive_thumbnail so the backend suppression alone
covers them.
Regression tests pin cancel-only-pending behavior, soft-deleted
suppression + archive_deleted=True flag, and the sanity guard that
live-archive fields keep flowing through unchanged.
This commit is contained in:
@@ -1833,3 +1833,85 @@ class TestAbortedStatusNormalisation:
|
||||
"""Verify 404 for non-existent batch."""
|
||||
response = await async_client.get("/api/v1/queue/batches/9999")
|
||||
assert response.status_code == 404
|
||||
|
||||
# ========================================================================
|
||||
# Soft-deleted archive handling (#1348 follow-up)
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_soft_delete_archive_cancels_pending_queue_items(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
|
||||
):
|
||||
"""Soft-deleting an archive cancels its pending queue items with a
|
||||
clear reason. The 3MF is gone from disk so the item can never
|
||||
dispatch — leaving it in 'pending' would 404-storm the queue page
|
||||
and confuse the user about why nothing prints."""
|
||||
from backend.app.services.archive import ArchiveService
|
||||
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(thumbnail_path="archives/test/test/thumbnail.png")
|
||||
pending = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
|
||||
completed = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="completed")
|
||||
|
||||
service = ArchiveService(db_session)
|
||||
assert await service.soft_delete_archive(archive.id) is True
|
||||
|
||||
await db_session.refresh(pending)
|
||||
await db_session.refresh(completed)
|
||||
assert pending.status == "cancelled"
|
||||
assert pending.waiting_reason == "Source archive deleted"
|
||||
# Historical rows untouched — they're audit-trail.
|
||||
assert completed.status == "completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_queue_api_hides_archive_surface_when_soft_deleted(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
|
||||
):
|
||||
"""Queue serializer must NOT populate archive_thumbnail / archive_name
|
||||
when the archive is soft-deleted — otherwise the frontend renders a
|
||||
broken <img> and 404-storms the thumbnail / plates / plate-thumbnail
|
||||
endpoints. archive_deleted=True signals the soft-deleted state so
|
||||
the UI can render a 'source deleted' badge."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
print_name="Test Print",
|
||||
thumbnail_path="archives/test/test/thumbnail.png",
|
||||
deleted_at=datetime.now(timezone.utc), # Pre-soft-deleted
|
||||
)
|
||||
item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="cancelled")
|
||||
|
||||
resp = await async_client.get("/api/v1/queue/")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
row = next((r for r in body if r["id"] == item.id), None)
|
||||
assert row is not None
|
||||
assert row["archive_deleted"] is True
|
||||
assert row["archive_thumbnail"] is None, "must not expose stale thumbnail path for soft-deleted archive"
|
||||
assert row["archive_name"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_queue_api_still_exposes_archive_surface_when_live(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
|
||||
):
|
||||
"""Sanity guard: the soft-delete suppression must not affect live
|
||||
archives. archive_name / archive_thumbnail still flow through and
|
||||
archive_deleted stays False."""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
print_name="Live Archive",
|
||||
thumbnail_path="archives/test/live/thumbnail.png",
|
||||
)
|
||||
item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
|
||||
|
||||
resp = await async_client.get("/api/v1/queue/")
|
||||
assert resp.status_code == 200
|
||||
row = next((r for r in resp.json() if r["id"] == item.id), None)
|
||||
assert row is not None
|
||||
assert row["archive_deleted"] is False
|
||||
assert row["archive_name"] == "Live Archive"
|
||||
assert row["archive_thumbnail"] == "archives/test/live/thumbnail.png"
|
||||
|
||||
Reference in New Issue
Block a user