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

fix(queue): update LibraryFile.print_count and last_printed_at on completion (#1008)

  Both fields have existed on the model and been shown in the File
  Manager for some time, but nothing ever wrote to them — every file in
  every library appeared to have never been printed.

  Now on_print_complete's queue-status update path calls a small
  _bump_library_file_usage_if_completed() helper that increments
  print_count and stamps last_printed_at on the source library file
  whenever a queued print completes successfully. Failed, cancelled and
  user-aborted prints are intentionally skipped so the fields represent
  successful usage rather than attempt count.

  Unblocks sorting the File Manager by last-printed date and is a
  prerequisite for the scheduled-purge feature requested in #1008,
  which is held until we see whether manual sort+bulk-delete covers the
  use case.
maziggy 4 месяцев назад
Родитель
Сommit
2bf397e33e
3 измененных файлов с 166 добавлено и 0 удалено
  1. 3 0
      CHANGELOG.md
  2. 23 0
      backend/app/main.py
  3. 140 0
      backend/tests/integration/test_print_queue_api.py

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ## [0.2.4b1] - Unreleased
 ## [0.2.4b1] - Unreleased
 
 
+### Fixed
+- **Library File Print-Usage Tracking** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — `LibraryFile.print_count` and `last_printed_at` are now updated on every successful queued print completion. Previously both fields were defined on the model and displayed in the File Manager, but nothing ever wrote to them — every file in every library showed as never printed. Now counts increment cumulatively and `last_printed_at` stamps the completion timestamp (UTC). Failed, cancelled and user-aborted prints are intentionally excluded, so the fields represent "successful usage" rather than "attempted usage." This unblocks sorting the File Manager by last-printed date and is a prerequisite for the scheduled-purge feature requested in #1008. Thanks to @cadtoolbox for the report.
+
 ### Improved
 ### Improved
 - **File Manager: Collapse Folders by Default** ([#996](https://github.com/maziggy/bambuddy/issues/996)) — Added a **Collapse** toggle next to **Wrap** in the File Manager sidebar header. When enabled, the folder tree opens with only top-level folders visible on every page load; disabling it restores the previous fully-expanded default. Toggling the preference also immediately re-collapses/re-expands the current tree — no reload required. Persisted to localStorage under `library-collapse-folders`, matching the existing `library-*` preference pattern. Thanks to @AshieTashi for the request.
 - **File Manager: Collapse Folders by Default** ([#996](https://github.com/maziggy/bambuddy/issues/996)) — Added a **Collapse** toggle next to **Wrap** in the File Manager sidebar header. When enabled, the folder tree opens with only top-level folders visible on every page load; disabling it restores the previous fully-expanded default. Toggling the preference also immediately re-collapses/re-expands the current tree — no reload required. Persisted to localStorage under `library-collapse-folders`, matching the existing `library-*` preference pattern. Thanks to @AshieTashi for the request.
 
 

+ 23 - 0
backend/app/main.py

@@ -431,6 +431,23 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
     return stored_ams_mapping
     return stored_ams_mapping
 
 
 
 
+async def _bump_library_file_usage_if_completed(db, item, queue_status: str) -> None:
+    """Increment LibraryFile.print_count and stamp last_printed_at when a queued
+    print completes successfully. Gated to status=='completed': failed, cancelled
+    and aborted prints do not count as usage. Caller is responsible for committing
+    the session. No-op when the queue item has no linked library file (e.g. reprints
+    from an archive). See #1008."""
+    if queue_status != "completed" or item.library_file_id is None:
+        return
+    from backend.app.models.library import LibraryFile
+
+    lib_file = await db.scalar(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+    if lib_file is None:
+        return
+    lib_file.print_count = (lib_file.print_count or 0) + 1
+    lib_file.last_printed_at = datetime.now(timezone.utc)
+
+
 def mark_printer_stopped_by_user(printer_id: int) -> None:
 def mark_printer_stopped_by_user(printer_id: int) -> None:
     """Mark that the active print on this printer was stopped by the user from the queue UI.
     """Mark that the active print on this printer was stopped by the user from the queue UI.
 
 
@@ -2649,6 +2666,12 @@ async def on_print_complete(printer_id: int, data: dict):
                     queue_status = "cancelled"
                     queue_status = "cancelled"
                 item.status = queue_status
                 item.status = queue_status
                 item.completed_at = datetime.now(timezone.utc)
                 item.completed_at = datetime.now(timezone.utc)
+
+                # Bump usage counters on the source library file so admins can
+                # sort by "last printed" and (eventually) auto-purge stale
+                # files — #1008.
+                await _bump_library_file_usage_if_completed(db, item, queue_status)
+
                 await db.commit()
                 await db.commit()
                 queue_item_id = item.id
                 queue_item_id = item.id
                 queue_auto_off = item.auto_off_after
                 queue_auto_off = item.auto_off_after

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

@@ -1482,6 +1482,146 @@ class TestAbortedStatusNormalisation:
 
 
         assert item.status == "completed"
         assert item.status == "completed"
 
 
+    # ========================================================================
+    # Library file usage tracking on print completion (#1008)
+    #
+    # These exercise the _bump_library_file_usage_if_completed helper directly
+    # rather than invoking the whole on_print_complete handler — that path
+    # spawns background asyncio tasks (notifications, MQTT relay, smart-plug)
+    # that are expensive to mock and have nothing to do with the bump logic.
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bump_library_file_usage_on_completed(self, printer_factory, db_session):
+        """Successful completion increments print_count and stamps last_printed_at."""
+        from datetime import datetime, timezone
+
+        from backend.app.main import _bump_library_file_usage_if_completed
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        lib_file = LibraryFile(
+            filename="benchy.gcode.3mf",
+            file_path="/data/library/benchy.gcode.3mf",
+            file_type="gcode.3mf",
+            file_size=1024,
+            print_count=0,
+            last_printed_at=None,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=lib_file.id,
+            status="printing",
+            position=1,
+        )
+
+        before = datetime.now(timezone.utc).replace(tzinfo=None)
+        await _bump_library_file_usage_if_completed(db_session, item, "completed")
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        assert lib_file.print_count == 1
+        assert lib_file.last_printed_at is not None
+        assert lib_file.last_printed_at >= before
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bump_library_file_usage_repeated_prints_increment_count(self, printer_factory, db_session):
+        """Each successful completion bumps print_count cumulatively."""
+        from backend.app.main import _bump_library_file_usage_if_completed
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        lib_file = LibraryFile(
+            filename="repeat.gcode.3mf",
+            file_path="/data/library/repeat.gcode.3mf",
+            file_type="gcode.3mf",
+            file_size=1024,
+            print_count=0,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=lib_file.id,
+            status="printing",
+            position=1,
+        )
+
+        for _ in range(3):
+            await _bump_library_file_usage_if_completed(db_session, item, "completed")
+
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+        assert lib_file.print_count == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("terminal_status", ["failed", "cancelled"])
+    async def test_bump_library_file_usage_skips_non_completed(self, printer_factory, db_session, terminal_status):
+        """Failed and cancelled prints must NOT count as usage."""
+        from backend.app.main import _bump_library_file_usage_if_completed
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        lib_file = LibraryFile(
+            filename="broken.gcode.3mf",
+            file_path="/data/library/broken.gcode.3mf",
+            file_type="gcode.3mf",
+            file_size=1024,
+            print_count=0,
+            last_printed_at=None,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=lib_file.id,
+            status="printing",
+            position=1,
+        )
+
+        await _bump_library_file_usage_if_completed(db_session, item, terminal_status)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        assert lib_file.print_count == 0
+        assert lib_file.last_printed_at is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bump_library_file_usage_skips_when_no_library_file_id(
+        self, printer_factory, archive_factory, db_session
+    ):
+        """Queue items without library_file_id (e.g. archive reprints) are a no-op."""
+        from backend.app.main import _bump_library_file_usage_if_completed
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory()
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=None,
+            archive_id=archive.id,
+            status="printing",
+            position=1,
+        )
+
+        # Must not raise.
+        await _bump_library_file_usage_if_completed(db_session, item, "completed")
+
     # ========================================================================
     # ========================================================================
     # Batch quantity tests
     # Batch quantity tests
     # ========================================================================
     # ========================================================================