Procházet zdrojové kódy

fix(archives): keep Quick Stats contribution when deleting a print (#1343)

  Reported by @IndividualGhost1905: printing the same model ten times and
  then deleting nine archive entries (to keep the file list tidy) silently
  rewound the totals on the Statistics page — total prints, filament,
  cost, and per-print energy all dropped back to whatever the surviving
  row contributed, as if the other nine prints had never happened.

  Root cause: every metric in get_archive_stats is recomputed live from
  PrintArchive rows via COUNT / SUM, so removing a row removes its
  contribution. Energy in the default "Total" mode already survived
  deletion because it reads the smart-plug lifetime counters — that's
  the architectural shape we now generalise to the rest.

  Fix: soft delete with opt-in hard purge.

  Backend:
  - New nullable, indexed deleted_at column on print_archives, dialect-
    conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
  - ArchiveService.soft_delete_archive flips deleted_at and removes the
    files from disk (still reclaims storage); the path-safety checks were
    extracted into _resolve_archive_dir_for_delete so soft and hard delete
    share the rules.
  - DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
  - Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
    fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
    detection (so a 1-live + 9-soft-deleted group no longer marks the
    survivor as a duplicate), and ArchiveComparisonService's "similar"
    suggestions. GET /stats and GET /slim deliberately do NOT filter so
    Quick Stats and the dashboard widgets keep counting deleted prints.

  Frontend:
  - ConfirmModal gained an optional children slot.
  - ArchivesPage (both card and detail views) own a per-instance
    deletePurgeStats boolean and render an opt-in checkbox in the delete
    dialog; resets to off on every close so the destructive option is
    never sticky.
  - api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
    when the box is ticked.
  - One new i18n key archives.modal.deletePurgeStats added across all 8
    locales (full German, English fallbacks elsewhere).
maziggy před 1 týdnem
rodič
revize
9ba1e34729

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CHANGELOG.md


+ 47 - 16
backend/app/api/routes/archives.py

@@ -283,7 +283,7 @@ async def list_archives(
                 PrintArchive.created_at,
                 PrintArchive.created_at,
                 PrintArchive.content_hash,
                 PrintArchive.content_hash,
                 func.lower(PrintArchive.print_name).label("print_name_lower"),
                 func.lower(PrintArchive.print_name).label("print_name_lower"),
-            ).where(or_(*duplicate_group_conditions))
+            ).where(or_(*duplicate_group_conditions), PrintArchive.deleted_at.is_(None))
         )
         )
 
 
         duplicate_groups_by_hash: dict[str, list[tuple[int, datetime]]] = defaultdict(list)
         duplicate_groups_by_hash: dict[str, list[tuple[int, datetime]]] = defaultdict(list)
@@ -484,12 +484,15 @@ async def search_archives(
             select(PrintArchive)
             select(PrintArchive)
             .options(selectinload(PrintArchive.project))
             .options(selectinload(PrintArchive.project))
             .where(
             .where(
-                (PrintArchive.print_name.ilike(like_pattern))
-                | (PrintArchive.filename.ilike(like_pattern))
-                | (PrintArchive.tags.ilike(like_pattern))
-                | (PrintArchive.notes.ilike(like_pattern))
-                | (PrintArchive.designer.ilike(like_pattern))
-                | (PrintArchive.filament_type.ilike(like_pattern))
+                (
+                    (PrintArchive.print_name.ilike(like_pattern))
+                    | (PrintArchive.filename.ilike(like_pattern))
+                    | (PrintArchive.tags.ilike(like_pattern))
+                    | (PrintArchive.notes.ilike(like_pattern))
+                    | (PrintArchive.designer.ilike(like_pattern))
+                    | (PrintArchive.filament_type.ilike(like_pattern))
+                ),
+                PrintArchive.deleted_at.is_(None),
             )
             )
             .order_by(PrintArchive.created_at.desc())
             .order_by(PrintArchive.created_at.desc())
         )
         )
@@ -509,8 +512,12 @@ async def search_archives(
     if not matched_ids:
     if not matched_ids:
         return []
         return []
 
 
-    # Fetch full archive records for matched IDs
-    query = select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id.in_(matched_ids))
+    # Fetch full archive records for matched IDs (excluding soft-deleted, #1343)
+    query = (
+        select(PrintArchive)
+        .options(selectinload(PrintArchive.project))
+        .where(PrintArchive.id.in_(matched_ids), PrintArchive.deleted_at.is_(None))
+    )
 
 
     # Apply additional filters
     # Apply additional filters
     if printer_id:
     if printer_id:
@@ -1046,7 +1053,9 @@ async def get_all_tags(
     Returns a list of tags sorted by count (descending), then by name.
     Returns a list of tags sorted by count (descending), then by name.
     """
     """
     # Query all archives with non-null tags
     # Query all archives with non-null tags
-    result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None)))
+    result = await db.execute(
+        select(PrintArchive.tags).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
+    )
     all_tags_rows = result.all()
     all_tags_rows = result.all()
 
 
     # Count occurrences of each tag
     # Count occurrences of each tag
@@ -1087,7 +1096,9 @@ async def rename_tag(
         return {"affected": 0}
         return {"affected": 0}
 
 
     # Find all archives containing the old tag
     # Find all archives containing the old tag
-    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
+    )
     archives = list(result.scalars().all())
     archives = list(result.scalars().all())
 
 
     affected = 0
     affected = 0
@@ -1123,7 +1134,9 @@ async def delete_tag(
     Returns the count of affected archives.
     Returns the count of affected archives.
     """
     """
     # Find all archives containing the tag
     # Find all archives containing the tag
-    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
+    )
     archives = list(result.scalars().all())
     archives = list(result.scalars().all())
 
 
     affected = 0
     affected = 0
@@ -1150,7 +1163,11 @@ async def get_archive(
     """Get a specific archive."""
     """Get a specific archive."""
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
-    if not archive:
+    # Soft-deleted archives are hidden from the UI (#1343) — surface them as
+    # 404 here too so a stale bookmark / direct URL doesn't expose a row the
+    # user has already removed. The hard-delete (?purge_stats=true) path
+    # bypasses this check by querying PrintArchive directly.
+    if not archive or archive.deleted_at is not None:
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
 
 
     # Find duplicates
     # Find duplicates
@@ -1494,6 +1511,15 @@ async def backfill_content_hashes(
 @router.delete("/{archive_id}")
 @router.delete("/{archive_id}")
 async def delete_archive(
 async def delete_archive(
     archive_id: int,
     archive_id: int,
+    purge_stats: bool = Query(
+        False,
+        description=(
+            "When false (default) the archive is soft-deleted — files removed "
+            "from disk, row hidden from listings, but its filament / energy / "
+            "time / cost contribution stays in Quick Stats. Set true to also "
+            "drop the row from statistics (#1343)."
+        ),
+    ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
@@ -1502,7 +1528,7 @@ async def delete_archive(
         )
         )
     ),
     ),
 ):
 ):
-    """Delete an archive."""
+    """Delete an archive (soft by default; ``?purge_stats=true`` to hard-delete)."""
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
 
 
     # Get archive first to check ownership
     # Get archive first to check ownership
@@ -1517,9 +1543,14 @@ async def delete_archive(
             raise HTTPException(403, "You can only delete your own archives")
             raise HTTPException(403, "You can only delete your own archives")
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
-    if not await service.delete_archive(archive_id):
+    if purge_stats:
+        if not await service.delete_archive(archive_id):
+            raise HTTPException(404, "Archive not found")
+        return {"status": "deleted", "purged_from_stats": True}
+
+    if not await service.soft_delete_archive(archive_id):
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
-    return {"status": "deleted"}
+    return {"status": "deleted", "purged_from_stats": False}
 
 
 
 
 @router.get("/{archive_id}/download")
 @router.get("/{archive_id}/download")

+ 13 - 0
backend/app/core/database.py

@@ -1916,6 +1916,19 @@ async def run_migrations(conn):
     # rendered on archive cards.
     # rendered on archive cards.
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN bed_type VARCHAR(64)")
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN bed_type VARCHAR(64)")
 
 
+    # Migration: Add deleted_at to print_archives (#1343)
+    # Soft-delete sentinel so deleting an archive entry from the UI no longer
+    # wipes its filament / time / cost contribution from Quick Stats. Listings
+    # hide rows where deleted_at IS NOT NULL; the stats endpoint counts them all.
+    # DATETIME on SQLite, TIMESTAMP on PostgreSQL (PG doesn't accept DATETIME on
+    # ALTER TABLE the same way it tolerates it inside CREATE TABLE).
+    _deleted_at_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
+    await _safe_execute(conn, f"ALTER TABLE print_archives ADD COLUMN deleted_at {_deleted_at_type}")
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_print_archives_deleted_at ON print_archives (deleted_at)",
+    )
+
     # Migration: Create smart_plug_energy_snapshots table (#941)
     # Migration: Create smart_plug_energy_snapshots table (#941)
     # Hourly snapshots of each plug's lifetime counter, so date-range queries in
     # Hourly snapshots of each plug's lifetime counter, so date-range queries in
     # "total consumption" energy mode can compute (last - first) deltas.
     # "total consumption" energy mode can compute (last - first) deltas.

+ 7 - 0
backend/app/models/archive.py

@@ -78,6 +78,13 @@ class PrintArchive(Base):
 
 
     # Timestamps
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    # Soft-delete sentinel (#1343). When non-null, the UI hides this archive
+    # from listings (its files have already been removed from disk) but the
+    # stats endpoint keeps counting it — deleting nine of ten Benchies no
+    # longer wipes their filament / time / cost contribution from Quick Stats.
+    # The opt-in "Also remove from statistics" checkbox in the delete dialog
+    # bypasses the soft-delete path and hard-deletes the row.
+    deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None, index=True)
 
 
     # User tracking (who uploaded/created this archive)
     # User tracking (who uploaded/created this archive)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)

+ 79 - 3
backend/app/services/archive.py

@@ -854,9 +854,13 @@ class ArchiveService:
         """
         """
         from sqlalchemy import func
         from sqlalchemy import func
 
 
+        # Soft-deleted archives don't appear in the listing (#1343), so they
+        # mustn't influence the duplicate-group counts either — otherwise a
+        # group with 1 live + 4 soft-deleted would still be flagged as a
+        # duplicate even though the user only sees one row.
         result = await self.db.execute(
         result = await self.db.execute(
             select(PrintArchive.content_hash)
             select(PrintArchive.content_hash)
-            .where(PrintArchive.content_hash.isnot(None))
+            .where(PrintArchive.content_hash.isnot(None), PrintArchive.deleted_at.is_(None))
             .group_by(PrintArchive.content_hash)
             .group_by(PrintArchive.content_hash)
             .having(func.count(PrintArchive.id) > 1)
             .having(func.count(PrintArchive.id) > 1)
         )
         )
@@ -866,7 +870,11 @@ class ArchiveService:
         # This avoids marking different files with the same name as duplicates
         # This avoids marking different files with the same name as duplicates
         result = await self.db.execute(
         result = await self.db.execute(
             select(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
             select(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
-            .where(PrintArchive.print_name.isnot(None), PrintArchive.content_hash.isnot(None))
+            .where(
+                PrintArchive.print_name.isnot(None),
+                PrintArchive.content_hash.isnot(None),
+                PrintArchive.deleted_at.is_(None),
+            )
             .group_by(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
             .group_by(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
             .having(func.count(PrintArchive.id) > 1)
             .having(func.count(PrintArchive.id) > 1)
         )
         )
@@ -895,6 +903,7 @@ class ArchiveService:
                     and_(
                     and_(
                         PrintArchive.content_hash == content_hash,
                         PrintArchive.content_hash == content_hash,
                         PrintArchive.id != archive_id,
                         PrintArchive.id != archive_id,
+                        PrintArchive.deleted_at.is_(None),
                     )
                     )
                 )
                 )
                 .order_by(PrintArchive.created_at.desc())
                 .order_by(PrintArchive.created_at.desc())
@@ -914,7 +923,7 @@ class ArchiveService:
         # Prefer strict name+hash matching when hash exists; fallback to name-only for legacy/manual
         # Prefer strict name+hash matching when hash exists; fallback to name-only for legacy/manual
         # archives that may not have a content_hash.
         # archives that may not have a content_hash.
         if print_name or makerworld_model_id:
         if print_name or makerworld_model_id:
-            conditions = [PrintArchive.id != archive_id]
+            conditions = [PrintArchive.id != archive_id, PrintArchive.deleted_at.is_(None)]
 
 
             name_conditions = []
             name_conditions = []
             if print_name:
             if print_name:
@@ -1198,6 +1207,10 @@ class ArchiveService:
         query = (
         query = (
             select(PrintArchive)
             select(PrintArchive)
             .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
             .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
+            # Hide soft-deleted rows from the listings (#1343). The stats
+            # endpoint deliberately does NOT add this filter so deleted
+            # archives keep contributing to Quick Stats.
+            .where(PrintArchive.deleted_at.is_(None))
             .order_by(PrintArchive.created_at.desc())
             .order_by(PrintArchive.created_at.desc())
         )
         )
 
 
@@ -1219,6 +1232,69 @@ class ArchiveService:
         result = await self.db.execute(query)
         result = await self.db.execute(query)
         return list(result.scalars().all())
         return list(result.scalars().all())
 
 
+    async def soft_delete_archive(self, archive_id: int) -> bool:
+        """Soft-delete an archive (#1343).
+
+        Removes the archive's files from disk (it disappears from the listings
+        and frees the storage) but flips the row's ``deleted_at`` so the stats
+        endpoint keeps counting its filament / energy / time / cost. The user
+        can opt into a hard delete via the "Also remove from statistics"
+        checkbox in the delete dialog — that path calls ``delete_archive``
+        instead and removes the row entirely.
+        """
+        archive = await self.get_archive(archive_id)
+        if not archive:
+            return False
+        if archive.deleted_at is not None:
+            # Already soft-deleted; nothing to do. The files were purged on
+            # the first soft-delete pass so there is nothing left on disk.
+            return True
+
+        dir_to_delete = self._resolve_archive_dir_for_delete(archive)
+
+        archive.deleted_at = datetime.now(timezone.utc)
+        await self.db.commit()
+
+        if dir_to_delete:
+            shutil.rmtree(dir_to_delete, ignore_errors=True)
+        return True
+
+    def _resolve_archive_dir_for_delete(self, archive: PrintArchive) -> Path | None:
+        """Return the on-disk directory that backs *archive*, after the same
+        two safety checks ``delete_archive`` enforces.
+
+        Extracted so soft-delete and hard-delete share the path-resolution
+        rules. Returns ``None`` when nothing should be removed from disk
+        (no file_path, path outside archive_dir, or path not deep enough).
+        """
+        if not archive.file_path or not archive.file_path.strip():
+            logger.error(
+                f"SECURITY: Refusing to delete files for archive {archive.id} - "
+                f"file_path is empty or invalid: '{archive.file_path}'"
+            )
+            return None
+
+        file_path = settings.base_dir / archive.file_path
+        if not file_path.exists():
+            return None
+
+        archive_dir = file_path.parent
+        try:
+            relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve())
+        except ValueError:
+            logger.error(
+                f"SECURITY: Refusing to delete archive {archive.id} - "
+                f"path {archive_dir} is outside archive directory {settings.archive_dir}"
+            )
+            return None
+        if len(relative_path.parts) < 1:
+            logger.error(
+                f"SECURITY: Refusing to delete archive {archive.id} - "
+                f"path {archive_dir} is not deep enough inside archive directory"
+            )
+            return None
+        return archive_dir
+
     async def delete_archive(self, archive_id: int) -> bool:
     async def delete_archive(self, archive_id: int) -> bool:
         """Delete an archive and its files."""
         """Delete an archive and its files."""
         archive = await self.get_archive(archive_id)
         archive = await self.get_archive(archive_id)

+ 4 - 1
backend/app/services/archive_comparison.py

@@ -201,13 +201,15 @@ class ArchiveComparisonService:
         # Find similar archives
         # Find similar archives
         similar = []
         similar = []
 
 
-        # By same print name
+        # By same print name (soft-deleted archives are hidden from the UI
+        # per #1343 so they must not surface here as "similar" either).
         if reference.print_name:
         if reference.print_name:
             result = await self.db.execute(
             result = await self.db.execute(
                 select(PrintArchive)
                 select(PrintArchive)
                 .where(
                 .where(
                     PrintArchive.id != archive_id,
                     PrintArchive.id != archive_id,
                     PrintArchive.print_name == reference.print_name,
                     PrintArchive.print_name == reference.print_name,
+                    PrintArchive.deleted_at.is_(None),
                 )
                 )
                 .order_by(PrintArchive.created_at.desc())
                 .order_by(PrintArchive.created_at.desc())
                 .limit(limit)
                 .limit(limit)
@@ -233,6 +235,7 @@ class ArchiveComparisonService:
                 .where(
                 .where(
                     PrintArchive.id != archive_id,
                     PrintArchive.id != archive_id,
                     PrintArchive.content_hash == reference.content_hash,
                     PrintArchive.content_hash == reference.content_hash,
+                    PrintArchive.deleted_at.is_(None),
                 )
                 )
                 .order_by(PrintArchive.created_at.desc())
                 .order_by(PrintArchive.created_at.desc())
                 .limit(limit - len(similar))
                 .limit(limit - len(similar))

+ 100 - 0
backend/tests/integration/test_archives_api.py

@@ -196,6 +196,106 @@ class TestArchivesAPI:
 
 
         assert response.status_code == 404
         assert response.status_code == 404
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_soft_delete_preserves_stats_contribution(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1343: deleting an archive without ``purge_stats`` keeps its
+        contribution in Quick Stats. The row vanishes from listings but the
+        filament / time / cost totals stay intact.
+        """
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            status="completed",
+            print_time_seconds=3600,
+            filament_used_grams=50.0,
+            cost=1.50,
+        )
+        archive_to_delete = await archive_factory(
+            printer.id,
+            status="completed",
+            print_time_seconds=7200,
+            filament_used_grams=100.0,
+            cost=3.00,
+        )
+
+        # Pre-delete: stats include both archives.
+        pre = (await async_client.get("/api/v1/archives/stats")).json()
+        assert pre["total_prints"] == 2
+        assert pre["total_filament_grams"] == 150.0
+        assert pre["total_cost"] == 4.50
+
+        # Soft delete (default — no purge_stats param).
+        resp = await async_client.delete(f"/api/v1/archives/{archive_to_delete.id}")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["purged_from_stats"] is False
+
+        # Listing hides the deleted archive…
+        listing = (await async_client.get("/api/v1/archives/")).json()
+        assert all(a["id"] != archive_to_delete.id for a in listing)
+
+        # …but stats still reflect both prints (the whole point of #1343).
+        post = (await async_client.get("/api/v1/archives/stats")).json()
+        assert post["total_prints"] == 2
+        assert post["total_filament_grams"] == 150.0
+        assert post["total_cost"] == 4.50
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_purge_stats_drops_archive_from_quick_stats(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1343: deleting with ``?purge_stats=true`` hard-deletes the row,
+        dropping its contribution from Quick Stats (the original behaviour,
+        now opt-in)."""
+        printer = await printer_factory()
+        keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
+        purge = await archive_factory(printer.id, status="completed", filament_used_grams=100.0)
+
+        resp = await async_client.delete(f"/api/v1/archives/{purge.id}?purge_stats=true")
+        assert resp.status_code == 200
+        assert resp.json()["purged_from_stats"] is True
+
+        stats = (await async_client.get("/api/v1/archives/stats")).json()
+        assert stats["total_prints"] == 1
+        assert stats["total_filament_grams"] == 50.0
+
+        # The kept archive is still listed.
+        listing = (await async_client.get("/api/v1/archives/")).json()
+        assert [a["id"] for a in listing] == [keep.id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_soft_deleted_archive_404_on_detail(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """A soft-deleted archive must 404 on GET — a stale bookmark or
+        direct URL should not expose a row the user has already removed."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        await async_client.delete(f"/api/v1/archives/{archive.id}")
+        resp = await async_client.get(f"/api/v1/archives/{archive.id}")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_soft_deleted_archive_hidden_from_search(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Search must skip soft-deleted archives. Uses the LIKE fallback by
+        querying a single-character pattern that the SQLite FTS5 rejects, so
+        the test covers the fallback path that the production FTS path also
+        respects."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="UniqueSoftDeleteCandidate")
+        await async_client.delete(f"/api/v1/archives/{archive.id}")
+        resp = await async_client.get("/api/v1/archives/search?q=UniqueSoftDeleteCandidate")
+        assert resp.status_code == 200
+        assert resp.json() == []
+
     # ========================================================================
     # ========================================================================
     # Statistics endpoints
     # Statistics endpoints
     # ========================================================================
     # ========================================================================

+ 6 - 2
frontend/src/api/client.ts

@@ -3463,8 +3463,12 @@ export const api = {
     }),
     }),
   toggleFavorite: (id: number) =>
   toggleFavorite: (id: number) =>
     request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
     request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
-  deleteArchive: (id: number) =>
-    request<void>(`/archives/${id}`, { method: 'DELETE' }),
+  // Soft-deletes by default (#1343): files removed from disk, row hidden
+  // from listings, but its filament / time / cost / energy contribution
+  // stays in Quick Stats. Pass purgeStats=true to hard-delete and drop the
+  // row from statistics too.
+  deleteArchive: (id: number, purgeStats: boolean = false) =>
+    request<void>(`/archives/${id}${purgeStats ? '?purge_stats=true' : ''}`, { method: 'DELETE' }),
 
 
   // ========== Archive auto-purge (#1008 follow-up) ==========
   // ========== Archive auto-purge (#1008 follow-up) ==========
   previewArchivePurge: (olderThanDays: number) =>
   previewArchivePurge: (olderThanDays: number) =>

+ 7 - 1
frontend/src/components/ConfirmModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { useEffect, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { AlertTriangle, Loader2 } from 'lucide-react';
 import { AlertTriangle, Loader2 } from 'lucide-react';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
@@ -14,6 +14,10 @@ interface ConfirmModalProps {
   variant?: 'danger' | 'warning' | 'default';
   variant?: 'danger' | 'warning' | 'default';
   isLoading?: boolean;
   isLoading?: boolean;
   loadingText?: string;
   loadingText?: string;
+  // Optional extra content rendered between the message and the buttons —
+  // used for opt-in checkboxes (e.g. the "Also remove from statistics"
+  // toggle in the archive delete confirmation, #1343).
+  children?: ReactNode;
   onConfirm: () => void;
   onConfirm: () => void;
   onCancel: () => void;
   onCancel: () => void;
 }
 }
@@ -28,6 +32,7 @@ export function ConfirmModal({
   variant = 'default',
   variant = 'default',
   isLoading = false,
   isLoading = false,
   loadingText,
   loadingText,
+  children,
   onConfirm,
   onConfirm,
   onCancel,
   onCancel,
 }: ConfirmModalProps) {
 }: ConfirmModalProps) {
@@ -78,6 +83,7 @@ export function ConfirmModal({
             <div className="flex-1">
             <div className="flex-1">
               <h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
               <h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
               <p className="text-bambu-gray text-sm whitespace-pre-line">{message}</p>
               <p className="text-bambu-gray text-sm whitespace-pre-line">{message}</p>
+              {children && <div className="mt-4">{children}</div>}
             </div>
             </div>
           </div>
           </div>
           <div className="flex gap-3 mt-6">
           <div className="flex gap-3 mt-6">

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -819,6 +819,7 @@ export default {
       deleteArchive: 'Archiv löschen',
       deleteArchive: 'Archiv löschen',
       deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
       deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
       deleteButton: 'Löschen',
       deleteButton: 'Löschen',
+      deletePurgeStats: 'Diesen Druck auch aus den Quick Stats entfernen (Filament, Zeit, Kosten, Energie)',
       removeSource3mf: 'Quell-3MF entfernen',
       removeSource3mf: 'Quell-3MF entfernen',
       removeSource3mfConfirm: 'Möchten Sie die Quell-3MF-Datei wirklich von "{{name}}" entfernen? Die ursprüngliche Slicer-Projektdatei wird gelöscht.',
       removeSource3mfConfirm: 'Möchten Sie die Quell-3MF-Datei wirklich von "{{name}}" entfernen? Die ursprüngliche Slicer-Projektdatei wird gelöscht.',
       removeButton: 'Entfernen',
       removeButton: 'Entfernen',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -819,6 +819,7 @@ export default {
       deleteArchive: 'Delete Archive',
       deleteArchive: 'Delete Archive',
       deleteConfirm: 'Are you sure you want to delete "{{name}}"? This action cannot be undone.',
       deleteConfirm: 'Are you sure you want to delete "{{name}}"? This action cannot be undone.',
       deleteButton: 'Delete',
       deleteButton: 'Delete',
+      deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
       removeSource3mf: 'Remove Source 3MF',
       removeSource3mf: 'Remove Source 3MF',
       removeSource3mfConfirm: 'Are you sure you want to remove the source 3MF file from "{{name}}"? This will delete the original slicer project file.',
       removeSource3mfConfirm: 'Are you sure you want to remove the source 3MF file from "{{name}}"? This will delete the original slicer project file.',
       removeButton: 'Remove',
       removeButton: 'Remove',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -819,6 +819,7 @@ export default {
       deleteArchive: 'Supprimer l\'archive',
       deleteArchive: 'Supprimer l\'archive',
       deleteConfirm: 'Supprimer "{{name}}" ? Cette action est irréversible.',
       deleteConfirm: 'Supprimer "{{name}}" ? Cette action est irréversible.',
       deleteButton: 'Supprimer',
       deleteButton: 'Supprimer',
+      deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
       removeSource3mf: 'Retirer Source 3MF',
       removeSource3mf: 'Retirer Source 3MF',
       removeSource3mfConfirm: 'Retirer le fichier 3MF de "{{name}}" ?',
       removeSource3mfConfirm: 'Retirer le fichier 3MF de "{{name}}" ?',
       removeButton: 'Retirer',
       removeButton: 'Retirer',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -819,6 +819,7 @@ export default {
       deleteArchive: 'Elimina Archivio',
       deleteArchive: 'Elimina Archivio',
       deleteConfirm: 'Sei sicuro di eliminare "{{name}}"? Questa azione non può essere annullata.',
       deleteConfirm: 'Sei sicuro di eliminare "{{name}}"? Questa azione non può essere annullata.',
       deleteButton: 'Elimina',
       deleteButton: 'Elimina',
+      deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
       removeSource3mf: 'Rimuovi Sorgente 3MF',
       removeSource3mf: 'Rimuovi Sorgente 3MF',
       removeSource3mfConfirm: 'Sei sicuro di rimuovere il file sorgente 3MF da "{{name}}"? Questo eliminerà il progetto slicer originale.',
       removeSource3mfConfirm: 'Sei sicuro di rimuovere il file sorgente 3MF da "{{name}}"? Questo eliminerà il progetto slicer originale.',
       removeButton: 'Rimuovi',
       removeButton: 'Rimuovi',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -818,6 +818,7 @@ export default {
       deleteArchive: 'アーカイブを削除',
       deleteArchive: 'アーカイブを削除',
       deleteConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。',
       deleteConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。',
       deleteButton: '削除',
       deleteButton: '削除',
+      deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
       removeSource3mf: 'ソース3MFを削除',
       removeSource3mf: 'ソース3MFを削除',
       removeSource3mfConfirm: '"{{name}}"からソース3MFファイルを削除してもよろしいですか?元のスライサープロジェクトファイルが削除されます。',
       removeSource3mfConfirm: '"{{name}}"からソース3MFファイルを削除してもよろしいですか?元のスライサープロジェクトファイルが削除されます。',
       removeButton: '削除',
       removeButton: '削除',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -819,6 +819,7 @@ export default {
       deleteArchive: 'Excluir Arquivo',
       deleteArchive: 'Excluir Arquivo',
       deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"? Esta ação não pode ser desfeita.',
       deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"? Esta ação não pode ser desfeita.',
       deleteButton: 'Excluir',
       deleteButton: 'Excluir',
+      deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
       removeSource3mf: 'Remover Source 3MF',
       removeSource3mf: 'Remover Source 3MF',
       removeSource3mfConfirm: 'Tem certeza de que deseja remover o arquivo source 3MF de "{{name}}"? Isso excluirá o arquivo original do projeto do fatiador.',
       removeSource3mfConfirm: 'Tem certeza de que deseja remover o arquivo source 3MF de "{{name}}"? Isso excluirá o arquivo original do projeto do fatiador.',
       removeButton: 'Remover',
       removeButton: 'Remover',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -819,6 +819,7 @@ export default {
       deleteArchive: '删除归档',
       deleteArchive: '删除归档',
       deleteConfirm: '确定要删除"{{name}}"吗?此操作无法撤销。',
       deleteConfirm: '确定要删除"{{name}}"吗?此操作无法撤销。',
       deleteButton: '删除',
       deleteButton: '删除',
+      deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
       removeSource3mf: '移除源 3MF',
       removeSource3mf: '移除源 3MF',
       removeSource3mfConfirm: '确定要从"{{name}}"中移除源 3MF 文件吗?这将删除原始切片项目文件。',
       removeSource3mfConfirm: '确定要从"{{name}}"中移除源 3MF 文件吗?这将删除原始切片项目文件。',
       removeButton: '移除',
       removeButton: '移除',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -819,6 +819,7 @@ export default {
       deleteArchive: '刪除歸檔',
       deleteArchive: '刪除歸檔',
       deleteConfirm: '確定要刪除"{{name}}"嗎?此操作無法復原。',
       deleteConfirm: '確定要刪除"{{name}}"嗎?此操作無法復原。',
       deleteButton: '刪除',
       deleteButton: '刪除',
+      deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
       removeSource3mf: '移除源 3MF',
       removeSource3mf: '移除源 3MF',
       removeSource3mfConfirm: '確定要從"{{name}}"中移除源 3MF 檔案嗎?這將刪除原始切片專案檔案。',
       removeSource3mfConfirm: '確定要從"{{name}}"中移除源 3MF 檔案嗎?這將刪除原始切片專案檔案。',
       removeButton: '移除',
       removeButton: '移除',

+ 46 - 8
frontend/src/pages/ArchivesPage.tsx

@@ -181,6 +181,9 @@ function ArchiveCard({
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  // #1343: when true, the delete also drops the row from Quick Stats. Default
+  // off — soft delete preserves the archive's filament/time/cost contribution.
+  const [deletePurgeStats, setDeletePurgeStats] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
@@ -340,7 +343,7 @@ function ArchiveCard({
   });
   });
 
 
   const deleteMutation = useMutation({
   const deleteMutation = useMutation({
-    mutationFn: () => api.deleteArchive(archive.id),
+    mutationFn: (purgeStats: boolean) => api.deleteArchive(archive.id, purgeStats),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       showToast(t('archives.toast.archiveDeleted'));
       showToast(t('archives.toast.archiveDeleted'));
@@ -1252,11 +1255,27 @@ function ArchiveCard({
           confirmText={t('archives.modal.deleteButton')}
           confirmText={t('archives.modal.deleteButton')}
           variant="danger"
           variant="danger"
           onConfirm={() => {
           onConfirm={() => {
-            deleteMutation.mutate();
+            deleteMutation.mutate(deletePurgeStats);
             setShowDeleteConfirm(false);
             setShowDeleteConfirm(false);
+            setDeletePurgeStats(false);
           }}
           }}
-          onCancel={() => setShowDeleteConfirm(false)}
-        />
+          onCancel={() => {
+            setShowDeleteConfirm(false);
+            setDeletePurgeStats(false);
+          }}
+        >
+          {/* #1343: opt-in checkbox — by default the archive is soft-deleted,
+              so its filament / time / cost contribution stays in Quick Stats. */}
+          <label className="flex items-start gap-2 cursor-pointer text-sm text-bambu-gray">
+            <input
+              type="checkbox"
+              className="mt-0.5 accent-red-500"
+              checked={deletePurgeStats}
+              onChange={(e) => setDeletePurgeStats(e.target.checked)}
+            />
+            <span>{t('archives.modal.deletePurgeStats')}</span>
+          </label>
+        </ConfirmModal>
       )}
       )}
 
 
       {/* Delete Source 3MF Confirmation */}
       {/* Delete Source 3MF Confirmation */}
@@ -1507,6 +1526,9 @@ function ArchiveListRow({
   const { hasPermission, canModify } = useAuth();
   const { hasPermission, canModify } = useAuth();
   const [showEdit, setShowEdit] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  // #1343: opt-in "Also remove from statistics" checkbox state. Default off
+  // — soft delete keeps the archive's contribution to Quick Stats.
+  const [deletePurgeStats, setDeletePurgeStats] = useState(false);
   const navigate = useNavigate();
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
@@ -1645,7 +1667,7 @@ function ArchiveListRow({
   });
   });
 
 
   const deleteMutation = useMutation({
   const deleteMutation = useMutation({
-    mutationFn: () => api.deleteArchive(archive.id),
+    mutationFn: (purgeStats: boolean) => api.deleteArchive(archive.id, purgeStats),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       showToast(t('archives.toast.archiveDeleted'));
       showToast(t('archives.toast.archiveDeleted'));
@@ -2204,11 +2226,27 @@ function ArchiveListRow({
           confirmText={t('archives.modal.deleteButton')}
           confirmText={t('archives.modal.deleteButton')}
           variant="danger"
           variant="danger"
           onConfirm={() => {
           onConfirm={() => {
-            deleteMutation.mutate();
+            deleteMutation.mutate(deletePurgeStats);
             setShowDeleteConfirm(false);
             setShowDeleteConfirm(false);
+            setDeletePurgeStats(false);
           }}
           }}
-          onCancel={() => setShowDeleteConfirm(false)}
-        />
+          onCancel={() => {
+            setShowDeleteConfirm(false);
+            setDeletePurgeStats(false);
+          }}
+        >
+          {/* #1343: opt-in checkbox — by default the archive is soft-deleted,
+              so its filament / time / cost contribution stays in Quick Stats. */}
+          <label className="flex items-start gap-2 cursor-pointer text-sm text-bambu-gray">
+            <input
+              type="checkbox"
+              className="mt-0.5 accent-red-500"
+              checked={deletePurgeStats}
+              onChange={(e) => setDeletePurgeStats(e.target.checked)}
+            />
+            <span>{t('archives.modal.deletePurgeStats')}</span>
+          </label>
+        </ConfirmModal>
       )}
       )}
 
 
       {/* Delete Source 3MF Confirmation */}
       {/* Delete Source 3MF Confirmation */}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CA6ngrew.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CMlectnM.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- 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-C_iAF6FB.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BkYu3kLs.css">
+    <script type="module" crossorigin src="/assets/index-CA6ngrew.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CMlectnM.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů