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

fix(archive): reprints start clean, deleted archives are archived anew

A reprint kept the previous run's failure reason after it completed; a
queued job whose archive was deleted printed with no archive,
notification or tracking; and a timelapse deleted or replaced during its
MP4 conversion came back when the conversion finished.
maziggy 6 дней назад
Родитель
Сommit
a8270b8fa2

+ 4 - 0
CHANGELOG.md

@@ -164,6 +164,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **Reprints and their archives** —
+  - A reprint that completes no longer keeps showing why the previous run of the same archive failed (for example a clogged nozzle).
+  - A queued job whose archive was deleted before it started is now archived anew when it prints, on a printer that archives its prints. Before, the print ran with no archive, no start notification and no usage tracking.
+  - A timelapse deleted while it was being converted to MP4, or replaced by a reprint's in the meantime, no longer comes back when the conversion finishes.
 - **Print dialog fixes** —
 - **Print dialog fixes** —
   - **Print Anyway** in a print to several printers now only waves through the printers whose filament warning it answered. Before, it went onto every selected printer, so one that was never checked also skipped the scheduler's filament checks.
   - **Print Anyway** in a print to several printers now only waves through the printers whose filament warning it answered. Before, it went onto every selected printer, so one that was never checked also skipped the scheduler's filament checks.
   - A job with printer alternatives whose plate dropdown was left untouched showed **Plate 1** but was queued with no plate, so it asked for every plate's filaments and the whole file's print time and could find no printer.
   - A job with printer alternatives whose plate dropdown was left untouched showed **Plate 1** but was queued with no plate, so it asked for every plate's filaments and the whole file's print time and could find no printer.

+ 17 - 1
backend/app/main.py

@@ -4644,6 +4644,10 @@ async def on_print_start(printer_id: int, data: dict):
                 # Update archive status to printing
                 # Update archive status to printing
                 archive.status = "printing"
                 archive.status = "printing"
                 archive.started_at = datetime.now(timezone.utc)
                 archive.started_at = datetime.now(timezone.utc)
+                # The previous run's outcome is not this one's: a reprint that
+                # completes must not keep showing why the last one failed.
+                archive.failure_reason = None
+                archive.completed_at = None
 
 
                 # The previous run's answer is still on this row and the
                 # The previous run's answer is still on this row and the
                 # completion prompt is gated on ``user_verdict is None`` (#1898),
                 # completion prompt is gated on ``user_verdict is None`` (#1898),
@@ -4883,8 +4887,20 @@ async def on_print_start(printer_id: int, data: dict):
                 # (#1403 follow-up — see pwostran's 2026-05-18 support bundle).
                 # (#1403 follow-up — see pwostran's 2026-05-18 support bundle).
                 await _capture_timelapse_baseline_at_start(printer, printer_id, logger, archive_id=archive.id)
                 await _capture_timelapse_baseline_at_start(printer, printer_id, logger, archive_id=archive.id)
                 await _forget_if_printer_gone(printer_id)
                 await _forget_if_printer_gone(printer_id)
+                return  # Skip creating a new archive
 
 
-            return  # Skip creating a new archive
+            # The archive was deleted after the job was queued.
+            if not printer.auto_archive:
+                # Only the expected print brought a printer that does not
+                # archive this far; with it gone, nothing is archived.
+                return
+            # Otherwise archive the print anew rather than drop it, so it still
+            # gets its notification, usage tracking and history.
+            logger.warning(
+                "Expected archive %s for printer %s no longer exists; archiving the print anew",
+                expected_archive_id,
+                printer_id,
+            )
 
 
         # Check if there's already a "printing" archive for this printer/file
         # Check if there's already a "printing" archive for this printer/file
         # This prevents duplicates when backend restarts during an active print
         # This prevents duplicates when backend restarts during an active print

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

@@ -2041,9 +2041,17 @@ async def _convert_timelapse_to_mp4(archive_id: int, source_path: Path) -> None:
 
 
             result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
             result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
             archive = result.scalar_one_or_none()
             archive = result.scalar_one_or_none()
-            if archive:
-                archive.timelapse_path = str(mp4_path.relative_to(settings.base_dir))
-                await db.commit()
+            source_rel = str(source_path.relative_to(settings.base_dir))
+            # Only if the archive still shows this video: one deleted while it
+            # converted, or replaced by a reprint's, must not come back.
+            if archive is None or archive.timelapse_path != source_rel:
+                logger.info(
+                    "Archive %s no longer shows %s; discarding its MP4 conversion", archive_id, source_path.name
+                )
+                mp4_path.unlink(missing_ok=True)
+                return
+            archive.timelapse_path = str(mp4_path.relative_to(settings.base_dir))
+            await db.commit()
 
 
         # Remove original non-MP4 file
         # Remove original non-MP4 file
         if source_path.exists():
         if source_path.exists():

+ 69 - 0
backend/tests/integration/test_reprint_adoption.py

@@ -0,0 +1,69 @@
+"""A print Bambuddy expected starts on a clean archive, or on a new one if the archive is gone."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+from sqlalchemy import func, select
+
+from backend.app.main import register_expected_print
+from backend.app.models.archive import PrintArchive
+from backend.tests.integration.test_external_print_confirmation_1898 import (  # noqa: F401
+    SUBTASK,
+    _clear_print_state,
+    _drive_print_start,
+)
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def test_a_reprint_does_not_keep_the_last_runs_failure(test_engine, db_session, printer_factory):
+    printer = await printer_factory()
+    printer_id = printer.id
+    archive = PrintArchive(
+        printer_id=printer_id,
+        filename=f"{SUBTASK}.gcode.3mf",
+        file_path=f"archives/test/{SUBTASK}.gcode.3mf",
+        file_size=1,
+        print_name=SUBTASK,
+        status="failed",
+        failure_reason="cloggedNozzle",
+    )
+    db_session.add(archive)
+    await db_session.flush()
+    archive_id = archive.id
+    await db_session.commit()
+    register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive_id)
+
+    await _drive_print_start(test_engine, SimpleNamespace(id=printer_id), download_ok=False)
+
+    db_session.expire_all()
+    row = await db_session.get(PrintArchive, archive_id)
+    assert (row.status, row.failure_reason, row.completed_at) == ("printing", None, None)
+
+
+async def test_a_print_whose_archive_was_deleted_is_archived_anew(test_engine, db_session, printer_factory):
+    printer_id = (await printer_factory()).id
+    register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", 999_999)
+
+    await _drive_print_start(test_engine, SimpleNamespace(id=printer_id), download_ok=False)
+
+    db_session.expire_all()
+    count = await db_session.scalar(
+        select(func.count()).select_from(PrintArchive).where(PrintArchive.printer_id == printer_id)
+    )
+    assert count == 1
+
+
+async def test_a_printer_that_does_not_archive_gets_no_archive_for_it(test_engine, db_session, printer_factory):
+    printer_id = (await printer_factory(auto_archive=False)).id
+    register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", 999_999)
+
+    await _drive_print_start(test_engine, SimpleNamespace(id=printer_id), download_ok=False)
+
+    db_session.expire_all()
+    count = await db_session.scalar(
+        select(func.count()).select_from(PrintArchive).where(PrintArchive.printer_id == printer_id)
+    )
+    assert count == 0

+ 18 - 3
backend/tests/unit/test_archive_filtering.py

@@ -663,8 +663,7 @@ class TestConvertTimelapseToMp4:
     """Test the background AVI-to-MP4 conversion."""
     """Test the background AVI-to-MP4 conversion."""
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_converts_avi_to_mp4(self, tmp_path):
-        """Should call FFmpeg to convert and update the DB path."""
+    async def _convert(self, tmp_path, shown: str):
         source = tmp_path / "video.avi"
         source = tmp_path / "video.avi"
         source.write_bytes(b"fake avi")
         source.write_bytes(b"fake avi")
         mp4_path = tmp_path / "video.mp4"
         mp4_path = tmp_path / "video.mp4"
@@ -675,7 +674,7 @@ class TestConvertTimelapseToMp4:
 
 
         mock_archive = MagicMock()
         mock_archive = MagicMock()
         mock_archive.id = 42
         mock_archive.id = 42
-        mock_archive.timelapse_path = "archives/42/video.avi"
+        mock_archive.timelapse_path = shown
 
 
         mock_session = AsyncMock()
         mock_session = AsyncMock()
         mock_session.__aenter__ = AsyncMock(return_value=mock_session)
         mock_session.__aenter__ = AsyncMock(return_value=mock_session)
@@ -703,6 +702,13 @@ class TestConvertTimelapseToMp4:
 
 
             await _convert_timelapse_to_mp4(42, source)
             await _convert_timelapse_to_mp4(42, source)
 
 
+        return mock_exec, mock_session, mock_archive, mp4_path
+
+    @pytest.mark.asyncio
+    async def test_converts_avi_to_mp4(self, tmp_path):
+        """Should call FFmpeg to convert and update the DB path."""
+        mock_exec, mock_session, mock_archive, _ = await self._convert(tmp_path, shown="video.avi")
+
         # FFmpeg should have been called
         # FFmpeg should have been called
         mock_exec.assert_called_once()
         mock_exec.assert_called_once()
         cmd_args = mock_exec.call_args[0]
         cmd_args = mock_exec.call_args[0]
@@ -714,6 +720,15 @@ class TestConvertTimelapseToMp4:
         mock_session.commit.assert_called_once()
         mock_session.commit.assert_called_once()
         assert mock_archive.timelapse_path == "video.mp4"
         assert mock_archive.timelapse_path == "video.mp4"
 
 
+    @pytest.mark.asyncio
+    async def test_an_archive_that_moved_on_keeps_what_it_shows(self, tmp_path):
+        """The video was deleted, or a reprint's replaced it, while it converted."""
+        _, mock_session, mock_archive, mp4_path = await self._convert(tmp_path, shown="other.mp4")
+
+        mock_session.commit.assert_not_called()
+        assert mock_archive.timelapse_path == "other.mp4"
+        assert not mp4_path.exists()
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_skips_when_no_ffmpeg(self, tmp_path):
     async def test_skips_when_no_ffmpeg(self, tmp_path):
         """Should log and return without converting when FFmpeg is unavailable."""
         """Should log and return without converting when FFmpeg is unavailable."""