Browse Source

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 1 month ago
parent
commit
2bf397e33e
3 changed files with 166 additions and 0 deletions
  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
 
+### 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
 - **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
 
 
+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:
     """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"
                 item.status = queue_status
                 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()
                 queue_item_id = item.id
                 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"
 
+    # ========================================================================
+    # 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
     # ========================================================================