Просмотр исходного кода

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.
maziggy 1 неделя назад
Родитель
Сommit
84ed28d5fa

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 30 - 18
backend/app/api/routes/print_queue.py

@@ -222,24 +222,36 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     }
     }
     response = PrintQueueItemResponse(**item_dict)
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
     if item.archive:
-        response.archive_name = item.archive.print_name or item.archive.filename
-        response.archive_thumbnail = item.archive.thumbnail_path
-        response.print_time_seconds = item.archive.print_time_seconds
-        response.filament_used_grams = item.archive.filament_used_grams
-        response.filament_type = item.archive.filament_type
-        response.filament_color = item.archive.filament_color
-        response.layer_height = item.archive.layer_height
-        response.nozzle_diameter = item.archive.nozzle_diameter
-        response.sliced_for_model = item.archive.sliced_for_model
-        if item.plate_id:
-            archive_path = settings.base_dir / item.archive.file_path
-            if archive_path.exists():
-                plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
-                plate_weight = sum(f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id))
-                if plate_time is not None:
-                    response.print_time_seconds = plate_time
-                if plate_weight > 0:
-                    response.filament_used_grams = plate_weight
+        # Soft-deleted archive: files are gone from disk but the row stays
+        # (its filament/cost contribution still flows into stats per #1343).
+        # Suppress the archive-derived UI surface so the queue page doesn't
+        # 404-storm the thumbnail / plates / plate-thumbnail endpoints — the
+        # frontend's existing truthy gate on archive_thumbnail covers it
+        # (#1348 follow-up). The archive_deleted flag lets the UI render a
+        # "source deleted" badge on these rows.
+        if item.archive.deleted_at is not None:
+            response.archive_deleted = True
+        else:
+            response.archive_name = item.archive.print_name or item.archive.filename
+            response.archive_thumbnail = item.archive.thumbnail_path
+            response.print_time_seconds = item.archive.print_time_seconds
+            response.filament_used_grams = item.archive.filament_used_grams
+            response.filament_type = item.archive.filament_type
+            response.filament_color = item.archive.filament_color
+            response.layer_height = item.archive.layer_height
+            response.nozzle_diameter = item.archive.nozzle_diameter
+            response.sliced_for_model = item.archive.sliced_for_model
+            if item.plate_id:
+                archive_path = settings.base_dir / item.archive.file_path
+                if archive_path.exists():
+                    plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
+                    plate_weight = sum(
+                        f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)
+                    )
+                    if plate_time is not None:
+                        response.print_time_seconds = plate_time
+                    if plate_weight > 0:
+                        response.filament_used_grams = plate_weight
     if item.library_file:
     if item.library_file:
         response.library_file_name = (
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None

+ 7 - 0
backend/app/schemas/print_queue.py

@@ -104,6 +104,13 @@ class PrintQueueItemResponse(BaseModel):
     # Nested info for UI (populated in route)
     # Nested info for UI (populated in route)
     archive_name: str | None = None
     archive_name: str | None = None
     archive_thumbnail: str | None = None
     archive_thumbnail: str | None = None
+    # True when the linked archive has been soft-deleted (its files are gone
+    # from disk). In that case the *archive_name* / *archive_thumbnail* /
+    # downstream metadata fields are intentionally left None so the frontend
+    # doesn't 404-storm the now-missing thumbnail / plates / plate-thumbnail
+    # endpoints (#1348 follow-up). Frontends can render a "source deleted"
+    # badge based on this flag.
+    archive_deleted: bool = False
     library_file_name: str | None = None  # Name of library file (if library_file_id is set)
     library_file_name: str | None = None  # Name of library file (if library_file_id is set)
     library_file_thumbnail: str | None = None  # Thumbnail of library file
     library_file_thumbnail: str | None = None  # Thumbnail of library file
     printer_name: str | None = None
     printer_name: str | None = None

+ 25 - 0
backend/app/services/archive.py

@@ -845,6 +845,30 @@ async def _null_print_log_thumbnail_paths(db: AsyncSession, archive_id: int) ->
     await db.execute(sa_update(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id).values(thumbnail_path=None))
     await db.execute(sa_update(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id).values(thumbnail_path=None))
 
 
 
 
+async def _cancel_pending_queue_items(db: AsyncSession, archive_id: int) -> None:
+    """Cancel pending queue items pointing at *archive_id* (#1348 follow-up).
+
+    Called from ``soft_delete_archive`` only — hard-delete is covered by the
+    ``ON DELETE CASCADE`` on ``print_queue.archive_id``.  A queue item
+    pointing at an archive whose 3MF has been removed from disk can never
+    actually dispatch, so cancelling at delete time both (a) tells the user
+    why the item disappeared from the pending list, and (b) stops the queue
+    page from 404-storming the archive thumbnail / plates / plate-thumbnail
+    endpoints when the row is rendered. Only ``pending`` items are touched;
+    ``printing`` is a rare race the printer-side fail-path catches, and
+    completed / failed / cancelled rows are historical and untouched.
+    """
+    from sqlalchemy import update as sa_update
+
+    from backend.app.models.print_queue import PrintQueueItem
+
+    await db.execute(
+        sa_update(PrintQueueItem)
+        .where(PrintQueueItem.archive_id == archive_id, PrintQueueItem.status == "pending")
+        .values(status="cancelled", waiting_reason="Source archive deleted")
+    )
+
+
 class ArchiveService:
 class ArchiveService:
     """Service for archiving print jobs."""
     """Service for archiving print jobs."""
 
 
@@ -1271,6 +1295,7 @@ class ArchiveService:
         dir_to_delete = self._resolve_archive_dir_for_delete(archive)
         dir_to_delete = self._resolve_archive_dir_for_delete(archive)
 
 
         await _null_print_log_thumbnail_paths(self.db, archive_id)
         await _null_print_log_thumbnail_paths(self.db, archive_id)
+        await _cancel_pending_queue_items(self.db, archive_id)
         archive.deleted_at = datetime.now(timezone.utc)
         archive.deleted_at = datetime.now(timezone.utc)
         await self.db.commit()
         await self.db.commit()
 
 

+ 82 - 0
backend/tests/integration/test_print_queue_api.py

@@ -1833,3 +1833,85 @@ class TestAbortedStatusNormalisation:
         """Verify 404 for non-existent batch."""
         """Verify 404 for non-existent batch."""
         response = await async_client.get("/api/v1/queue/batches/9999")
         response = await async_client.get("/api/v1/queue/batches/9999")
         assert response.status_code == 404
         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"

+ 4 - 0
frontend/src/api/client.ts

@@ -1691,6 +1691,10 @@ export interface PrintQueueItem {
   created_at: string;
   created_at: string;
   archive_name?: string | null;
   archive_name?: string | null;
   archive_thumbnail?: string | null;
   archive_thumbnail?: string | null;
+  // True when the linked archive has been soft-deleted; archive_name /
+  // archive_thumbnail / downstream metadata are left null in that case so
+  // the UI doesn't 404-storm the now-missing endpoints (#1348 follow-up).
+  archive_deleted?: boolean;
   library_file_name?: string | null;
   library_file_name?: string | null;
   library_file_thumbnail?: string | null;
   library_file_thumbnail?: string | null;
   printer_name?: string | null;
   printer_name?: string | null;

+ 4 - 2
frontend/src/pages/QueuePage.tsx

@@ -325,11 +325,13 @@ function SortableQueueItem({
 
 
   // Determine if we're printing a library file
   // Determine if we're printing a library file
   const isLibraryFile = !!item.library_file_id && !item.archive_id;
   const isLibraryFile = !!item.library_file_id && !item.archive_id;
-  // Fetch archive plate details
+  // Fetch archive plate details. Skip when the linked archive has been
+  // soft-deleted (#1348 follow-up): its 3MF is gone from disk so the
+  // /plates endpoint just 404-storms the queue page.
   const { data: archivePlatesData } = useQuery({
   const { data: archivePlatesData } = useQuery({
     queryKey: ['archive-plates', item.archive_id],
     queryKey: ['archive-plates', item.archive_id],
     queryFn: () => api.getArchivePlates(item.archive_id!),
     queryFn: () => api.getArchivePlates(item.archive_id!),
-    enabled: !!item.archive_id && !isLibraryFile,
+    enabled: !!item.archive_id && !isLibraryFile && !item.archive_deleted,
   });
   });
 
 
   // Fetch library file plate details
   // Fetch library file plate details

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CFzqkJEl.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CRZ2F5mH.js"></script>
+    <script type="module" crossorigin src="/assets/index-CFzqkJEl.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов